Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f24616d342 | |||
| 6c9029c496 | |||
| 8cc329ab17 | |||
| 76fbdefeaa | |||
| f34016a4a5 | |||
| 5c82e8bd9f | |||
| 40c9bf7988 | |||
| 1d7f3e2136 | |||
| ace76183e5 | |||
| f1956a08ff | |||
| e8f868e907 |
Binary file not shown.
@@ -0,0 +1,139 @@
|
|||||||
|
# Wdrożenie usługi `render` — raport PDF (PRE-24)
|
||||||
|
|
||||||
|
Instrukcja krok po kroku dla nowego komponentu. Dotyczy dwóch repozytoriów:
|
||||||
|
|
||||||
|
| Repo | Co wnosi |
|
||||||
|
|---|---|
|
||||||
|
| `gitea/astrololo` | usługa `services/render`, klient w prezentacji, przycisk „Pobierz PDF", wariant kosmogramu do druku |
|
||||||
|
| `gitea/deploy` | `astrololo/render.yaml`, adres i klucz łącza w `presentation.yaml` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dlaczego osobna usługa
|
||||||
|
|
||||||
|
TeX Live waży setki megabajtów. W obrazie prezentacji spowalniałby każdy build
|
||||||
|
i deploy, a przy każdej poprawce w CSS trzeba by go ciągnąć od nowa. Osobno:
|
||||||
|
obraz produktu zostaje mały, TeX aktualizuje się niezależnie, a **awaria renderu
|
||||||
|
nie kładzie aplikacji** — przestaje działać wyłącznie przycisk „Pobierz PDF".
|
||||||
|
|
||||||
|
To ta sama zasada, co przy izolacji silnika swisseph (LOG-27).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 1 — klucz łącza (PRZED wdrożeniem)
|
||||||
|
|
||||||
|
Usługa dostaje **cały raport**: dane urodzeniowe i opisy z baz. Łącze jest
|
||||||
|
szyfrowane AES-256-GCM, własnym, **trzecim** kluczem — przejęcie go nie może
|
||||||
|
otwierać łącza do logiki ani do danych.
|
||||||
|
|
||||||
|
Sekret `astrololo-link` już istnieje (PRE-16); dokładamy do niego trzeci klucz,
|
||||||
|
**zachowując dwa dotychczasowe**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo create secret generic astrololo-link \
|
||||||
|
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link \
|
||||||
|
-o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
|
||||||
|
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link \
|
||||||
|
-o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
|
||||||
|
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
Sprawdzenie — mają być **trzy** klucze:
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Uwaga:** `LINK_ENCRYPTION_REQUIRED=true` działa fail-closed. Bez klucza pod
|
||||||
|
> `render` **nie wstanie** — i tak ma być. Usługa, która wstała i po cichu nie
|
||||||
|
> szyfruje, jest gorsza niż CrashLoop, bo awarii nie widać.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 2 — obraz usługi
|
||||||
|
|
||||||
|
Obraz budowany jest osobnym workflow (jak silnik swisseph — nie wchodzi do
|
||||||
|
głównego pipeline'u produktu). Pierwszy build trwa dłużej, bo ciągnie TeX Live.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get deploy render -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
|
||||||
|
```
|
||||||
|
|
||||||
|
Pakiety w obrazie dobrane **wąsko** (nie `texlive-full`, który ma kilka GB):
|
||||||
|
|
||||||
|
| Pakiet | Po co |
|
||||||
|
|---|---|
|
||||||
|
| `texlive-xetex` | silnik **XeLaTeX** — konieczny, bo raport ma polskie znaki i glify astrologiczne; `pdflatex` ich nie złoży |
|
||||||
|
| `texlive-latex-recommended` | `geometry`, `graphicx` |
|
||||||
|
| `fonts-dejavu-core` | jeden font na polskie znaki **i** symbole (♄ ♓ ☉) |
|
||||||
|
| `librsvg2-bin` | `rsvg-convert` — SVG kosmogramu → PDF (LaTeX nie wstawia SVG wprost) |
|
||||||
|
|
||||||
|
Dockerfile sprawdza obecność obu narzędzi **przy budowie**, więc zepsuty obraz
|
||||||
|
nie dojedzie na produkcję niezauważony.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 3 — merge manifestów
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr merge --login gitea --repo gitea/deploy <numer>
|
||||||
|
kubectl -n astrololo rollout status deploy/render deploy/presentation
|
||||||
|
```
|
||||||
|
|
||||||
|
Wjeżdża naraz: `Deployment` + `Service` renderu (ClusterIP — **bez** Ingressu
|
||||||
|
i NodePortu, nie ma powodu sięgać do niej z zewnątrz) oraz `RENDER_URL`
|
||||||
|
i trzeci klucz w prezentacji.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 4 — sprawdzenie
|
||||||
|
|
||||||
|
### Narzędzia są na miejscu
|
||||||
|
`/health` raportuje obecność `xelatex` i `rsvg-convert`, żeby zepsuty obraz
|
||||||
|
było widać od razu, a nie dopiero przy pierwszym raporcie:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/presentation -- \
|
||||||
|
python -c "import httpx; print(httpx.get('http://render:8004/health').text)"
|
||||||
|
```
|
||||||
|
Oczekiwane: `{"status":"ok","layer":"render","tools":{"xelatex":true,"rsvg-convert":true}}`.
|
||||||
|
Gdy `status` to `degraded` — obraz zbudował się bez któregoś narzędzia.
|
||||||
|
|
||||||
|
### Łącze faktycznie szyfruje
|
||||||
|
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **z poprawnym tokenem,
|
||||||
|
ale bez szyfrowania**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/presentation -- python -c "
|
||||||
|
import httpx, os
|
||||||
|
r = httpx.post('http://render:8004/pdf', json={'person':'test'},
|
||||||
|
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||||
|
print(r.status_code, r.text[:80])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Sam token już
|
||||||
|
nie wystarcza — o to chodziło.
|
||||||
|
|
||||||
|
### Raport od końca do końca
|
||||||
|
W przeglądarce: **Skompiluj** → wypełnij dane → *Złóż podsumowanie* → *Pobierz PDF*.
|
||||||
|
PDF ma zacząć się od imienia i nazwiska, potem wprowadzone dane, **rysunek
|
||||||
|
kosmogramu**, a po nim interpretacja natalna i predykcje okresowe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Czego ta wersja nie załatwia
|
||||||
|
|
||||||
|
- **Kompilacja PDF nie została sprawdzona end-to-end** w środowisku, w którym
|
||||||
|
powstawała — nie było tam ani TeX Live, ani runtime'u kontenerów. Sprawdzone
|
||||||
|
jest wszystko dookoła: generowanie źródła `.tex` (w tym ucieczka znaków
|
||||||
|
specjalnych), szyfrowanie łącza, kontrakt API i samowystarczalność SVG.
|
||||||
|
**Pierwsze uruchomienie na klastrze trzeba obejrzeć.**
|
||||||
|
- **Dzielenie wyrazów** jest angielskie — nie wciągamy `polyglossia`, żeby nie
|
||||||
|
puchł obraz. Tekst składa się poprawnie, tylko przenoszenie bywa nieoptymalne.
|
||||||
|
- **Brak kolejki**. Długi raport blokuje jedno połączenie na czas kompilacji
|
||||||
|
(timeout klienta: 180 s). Przy większym ruchu warto dołożyć zadania w tle.
|
||||||
|
- **Glify w tekście od modelu**. Kosmogram idzie jako obraz (rysuje go
|
||||||
|
`rsvg-convert` z DejaVu), ale gdyby model wplótł symbole w prozę, złoży je
|
||||||
|
XeLaTeX — też z DejaVu. Jeśli któregoś zabraknie, LaTeX zgłasza
|
||||||
|
„Missing character" w logu i **nie drukuje znaku**; warto zerknąć w log
|
||||||
|
pierwszego raportu.
|
||||||
@@ -65,6 +65,10 @@ CONTENT_TYPE = "application/vnd.astrololo.enc"
|
|||||||
|
|
||||||
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||||
|
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||||
|
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||||
|
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||||
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
REQUEST, RESPONSE = b"req", b"res"
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def _shift_pos(pdict: dict, off: float) -> None:
|
|||||||
|
|
||||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||||
|
from app.engine import glyphs as GL
|
||||||
from app.engine.aspects import find_aspects
|
from app.engine.aspects import find_aspects
|
||||||
from app.engine.houses import mean_obliquity
|
from app.engine.houses import mean_obliquity
|
||||||
from app.engine.out_of_zodiac import (
|
from app.engine.out_of_zodiac import (
|
||||||
@@ -64,6 +65,10 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
result["parallels"] = find_declination_aspects(result["positions"])
|
result["parallels"] = find_declination_aspects(result["positions"])
|
||||||
result["antiscia"] = find_antiscia(result["positions"])
|
result["antiscia"] = find_antiscia(result["positions"])
|
||||||
|
|
||||||
|
# glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku
|
||||||
|
for a in result["aspects"]:
|
||||||
|
a["glyph"] = GL.aspect_glyph(a["aspect"])
|
||||||
|
|
||||||
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||||
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||||
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||||
@@ -73,6 +78,13 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
for pdict in result["positions"]:
|
for pdict in result["positions"]:
|
||||||
_shift_pos(pdict, off)
|
_shift_pos(pdict, off)
|
||||||
|
|
||||||
|
# glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU
|
||||||
|
# zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram.
|
||||||
|
for pdict in result["positions"]:
|
||||||
|
pdict["glyph"] = GL.glyph_for(pdict["name"])
|
||||||
|
pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"])
|
||||||
|
result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS]
|
||||||
|
|
||||||
if not hasattr(engine, "sidereal"):
|
if not hasattr(engine, "sidereal"):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -89,9 +101,13 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||||
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||||
}
|
}
|
||||||
|
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||||
|
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||||
result["cusps"] = [
|
result["cusps"] = [
|
||||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||||
"in_sign": in_sign(norm360(c - off))}
|
"in_sign": in_sign(norm360(c - off)),
|
||||||
|
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
|
||||||
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||||
for i, c in enumerate(cusp_list)
|
for i, c in enumerate(cusp_list)
|
||||||
]
|
]
|
||||||
for pdict, obj in zip(result["positions"], positions):
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
@@ -110,7 +126,9 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||||
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||||
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||||
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
|
"house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej
|
||||||
|
"glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22)
|
||||||
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])}
|
||||||
for lot in compute_lots(pts, day, lots_method)
|
for lot in compute_lots(pts, day, lots_method)
|
||||||
]
|
]
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||||
|
|
||||||
|
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne
|
||||||
|
pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami.
|
||||||
|
|
||||||
|
DWIE zasady z bazy wymagań, obie krytyczne:
|
||||||
|
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma
|
||||||
|
domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu,
|
||||||
|
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
|
||||||
|
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
|
||||||
|
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst)
|
||||||
|
normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez
|
||||||
|
dawał ten sam wynik.
|
||||||
|
|
||||||
|
Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia
|
||||||
|
edytorów i samodokumentujące (widać, który to znak Unicode).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# selektory wariantu prezentacji
|
||||||
|
_TEXT = "︎" # VS15 — wymusza glif tekstowy (czarno-biały)
|
||||||
|
_EMOJI = "️" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse
|
||||||
|
|
||||||
|
|
||||||
|
def _t(cp: str) -> str:
|
||||||
|
"""Znak z wymuszoną prezentacją tekstową (dokleja VS15)."""
|
||||||
|
return cp + _TEXT
|
||||||
|
|
||||||
|
|
||||||
|
# ── planety i światła ────────────────────────────────────────────────────
|
||||||
|
# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie.
|
||||||
|
PLANET = {
|
||||||
|
"Sun": "☉", # ☉
|
||||||
|
"Moon": "☽", # ☽
|
||||||
|
"Mercury": "☿", # ☿
|
||||||
|
"Venus": _t("♀"), # ♀︎
|
||||||
|
"Mars": _t("♂"), # ♂︎
|
||||||
|
"Jupiter": "♃", # ♃
|
||||||
|
"Saturn": "♄", # ♄
|
||||||
|
"Uranus": "♅", # ♅
|
||||||
|
"Neptune": "♆", # ♆
|
||||||
|
"Pluto": "♇", # ♇
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── punkty wirtualne ─────────────────────────────────────────────────────
|
||||||
|
POINT = {
|
||||||
|
"North Node": "☊", # ☊
|
||||||
|
"South Node": "☋", # ☋
|
||||||
|
"Lilith": "⚸", # ⚸ (Black Moon Lilith)
|
||||||
|
"Chiron": "⚷", # ⚷
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Lots (punkty arabskie) ───────────────────────────────────────────────
|
||||||
|
# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie
|
||||||
|
# wspieranego symbolu → zwracamy None i UI pokazuje nazwę.
|
||||||
|
LOT = {
|
||||||
|
"Fortune": "⊗", # ⊗
|
||||||
|
"Part of Fortune": "⊗",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ──────
|
||||||
|
_SIGN_CP = {
|
||||||
|
"Aries": "♈", "Taurus": "♉", "Gemini": "♊", "Cancer": "♋",
|
||||||
|
"Leo": "♌", "Virgo": "♍", "Libra": "♎", "Scorpio": "♏",
|
||||||
|
"Sagittarius": "♐", "Capricorn": "♑", "Aquarius": "♒", "Pisces": "♓",
|
||||||
|
}
|
||||||
|
SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()}
|
||||||
|
|
||||||
|
# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ─────────────
|
||||||
|
ASPECT = {
|
||||||
|
"conjunction": "☌", # ☌
|
||||||
|
"opposition": "☍", # ☍
|
||||||
|
"trine": "△", # △
|
||||||
|
"square": "□", # □
|
||||||
|
"sextile": "⚹", # ⚹
|
||||||
|
"semisextile": "⚺", # ⚺
|
||||||
|
"quincunx": "⚻", # ⚻
|
||||||
|
"semisquare": "∠", # ∠
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── ruch ─────────────────────────────────────────────────────────────────
|
||||||
|
RETROGRADE = "℞" # ℞
|
||||||
|
DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu
|
||||||
|
|
||||||
|
|
||||||
|
# ── forward: nazwa → glif ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def glyph_for(name: str) -> str | None:
|
||||||
|
"""Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak."""
|
||||||
|
return PLANET.get(name) or POINT.get(name) or LOT.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_glyph(sign: str) -> str | None:
|
||||||
|
return SIGN.get(sign)
|
||||||
|
|
||||||
|
|
||||||
|
def aspect_glyph(aspect: str) -> str | None:
|
||||||
|
return ASPECT.get(aspect)
|
||||||
|
|
||||||
|
|
||||||
|
# ── reverse: glif → nazwa ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _strip_variants(g: str) -> str:
|
||||||
|
"""Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz."""
|
||||||
|
return g.replace(_TEXT, "").replace(_EMOJI, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _reverse(mapping: dict[str, str]) -> dict[str, str]:
|
||||||
|
# pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune')
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for name, g in mapping.items():
|
||||||
|
out.setdefault(_strip_variants(g), name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}})
|
||||||
|
_SIGN_REV = _reverse(SIGN)
|
||||||
|
_ASPECT_REV = _reverse(ASPECT)
|
||||||
|
|
||||||
|
|
||||||
|
def name_for_glyph(g: str) -> str | None:
|
||||||
|
"""Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu)."""
|
||||||
|
return _PLANET_POINT_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
def sign_for_glyph(g: str) -> str | None:
|
||||||
|
return _SIGN_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
def aspect_for_glyph(g: str) -> str | None:
|
||||||
|
return _ASPECT_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ────────────────
|
||||||
|
# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF").
|
||||||
|
# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona
|
||||||
|
# abbreviations.expand: tam token → słowo, tu token → symbol.
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif
|
||||||
|
_TOKEN_GLYPH: dict[str, str] = {}
|
||||||
|
_ABBR_TO_NAME = {
|
||||||
|
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars",
|
||||||
|
"Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||||
|
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||||
|
"PF": "Fortune", "Fortune": "Fortune",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _ABBR_TO_NAME.items():
|
||||||
|
_g = glyph_for(_name)
|
||||||
|
if _g:
|
||||||
|
_TOKEN_GLYPH[_abbr] = _g
|
||||||
|
_SIGN_ABBR_NAME = {
|
||||||
|
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo",
|
||||||
|
"Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius",
|
||||||
|
"Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _SIGN_ABBR_NAME.items():
|
||||||
|
_TOKEN_GLYPH[_abbr] = SIGN[_name]
|
||||||
|
_ASP_ABBR_NAME = {
|
||||||
|
"conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square",
|
||||||
|
"sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _ASP_ABBR_NAME.items():
|
||||||
|
if _name in ASPECT:
|
||||||
|
_TOKEN_GLYPH[_abbr] = ASPECT[_name]
|
||||||
|
|
||||||
|
_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def glyphify(text: str) -> str:
|
||||||
|
"""Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo
|
||||||
|
»Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08')."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text)
|
||||||
|
out = _re.sub(r"\bR[x]?\b", RETROGRADE, out)
|
||||||
|
return out
|
||||||
@@ -65,6 +65,10 @@ CONTENT_TYPE = "application/vnd.astrololo.enc"
|
|||||||
|
|
||||||
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||||
|
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||||
|
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||||
|
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||||
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
REQUEST, RESPONSE = b"req", b"res"
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||||
|
|
||||||
|
Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma
|
||||||
|
symbol), DWUKIERUNKOWE (symbol → tekst z powrotem) i TEKSTOWE, nigdy emoji
|
||||||
|
(DAN-18) — inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych
|
||||||
|
symboli.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import glyphs as G
|
||||||
|
from app.engine.aspects import MAJOR
|
||||||
|
from app.engine.formats import SIGNS
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS
|
||||||
|
|
||||||
|
VS15, VS16 = "︎", "️"
|
||||||
|
|
||||||
|
|
||||||
|
def _all_glyphs():
|
||||||
|
return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values())
|
||||||
|
+ list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- kompletność
|
||||||
|
|
||||||
|
def test_every_default_object_has_a_glyph():
|
||||||
|
"""Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole
|
||||||
|
zostanie dziura."""
|
||||||
|
missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None]
|
||||||
|
assert not missing, f"obiekty bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sign_has_a_glyph():
|
||||||
|
missing = [s for s in SIGNS if G.sign_glyph(s) is None]
|
||||||
|
assert not missing, f"znaki bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_major_aspect_has_a_glyph():
|
||||||
|
missing = [a for a in MAJOR if G.aspect_glyph(a) is None]
|
||||||
|
assert not missing, f"aspekty główne bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_and_retrograde_from_requirement():
|
||||||
|
"""Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja)."""
|
||||||
|
assert G.glyph_for("Fortune") == "⊗"
|
||||||
|
assert G.RETROGRADE == "℞"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ DAN-18: tekst, nie emoji
|
||||||
|
|
||||||
|
def test_no_glyph_uses_emoji_variation_selector():
|
||||||
|
"""VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE."""
|
||||||
|
offenders = [g for g in _all_glyphs() if VS16 in g]
|
||||||
|
assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signs_force_text_presentation():
|
||||||
|
"""Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15."""
|
||||||
|
for s in SIGNS:
|
||||||
|
assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_venus_mars_force_text_but_sun_does_not():
|
||||||
|
"""♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny)."""
|
||||||
|
assert VS15 in G.glyph_for("Venus")
|
||||||
|
assert VS15 in G.glyph_for("Mars")
|
||||||
|
assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- dwukierunkowość
|
||||||
|
|
||||||
|
def test_object_round_trip():
|
||||||
|
for name in DEFAULT_OBJECTS:
|
||||||
|
assert G.name_for_glyph(G.glyph_for(name)) == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_round_trip():
|
||||||
|
for s in SIGNS:
|
||||||
|
assert G.sign_for_glyph(G.sign_glyph(s)) == s
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_round_trip():
|
||||||
|
for a in MAJOR:
|
||||||
|
assert G.aspect_for_glyph(G.aspect_glyph(a)) == a
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_tolerates_missing_variant_selector():
|
||||||
|
"""Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie
|
||||||
|
dowolna forma (DAN-17: łańcuch znosi każdy Unicode)."""
|
||||||
|
assert G.sign_for_glyph("♓") == "Pisces" # ♓ bez VS15
|
||||||
|
assert G.sign_for_glyph("♓" + VS15) == "Pisces" # ♓ z VS15
|
||||||
|
assert G.name_for_glyph("♀") == "Venus" # ♀ bez VS15
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- glifikacja tekstu
|
||||||
|
|
||||||
|
def test_glyphify_matches_requirement_example():
|
||||||
|
"""Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗."""
|
||||||
|
out = G.glyphify("[Sa [Pis 26°08' [conj [PF")
|
||||||
|
assert G.glyph_for("Saturn") in out
|
||||||
|
assert G.sign_glyph("Pisces") in out
|
||||||
|
assert G.aspect_glyph("conjunction") in out
|
||||||
|
assert G.glyph_for("Fortune") in out
|
||||||
|
assert "26°08'" in out # stopnie nietknięte
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphify_handles_retrograde_and_leaves_unknown():
|
||||||
|
out = G.glyphify("[Ma Rx w [Xyz")
|
||||||
|
assert G.glyph_for("Mars") in out
|
||||||
|
assert G.RETROGRADE in out
|
||||||
|
assert "[Xyz" in out # nieznany token bez zmian
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphify_empty_is_safe():
|
||||||
|
assert G.glyphify("") == ""
|
||||||
@@ -342,13 +342,13 @@ def test_incremental_unframing_handles_split_frames(link):
|
|||||||
|
|
||||||
# ----------------------------------------------- trzy kopie muszą być identyczne
|
# ----------------------------------------------- trzy kopie muszą być identyczne
|
||||||
|
|
||||||
def test_all_three_services_share_the_same_module():
|
def test_all_services_share_the_same_module():
|
||||||
"""Moduł jest skopiowany do trzech niezależnych usług (nie mają wspólnej
|
"""Moduł jest skopiowany do KAŻDEJ usługi, która szyfruje łącze (nie mają wspólnej
|
||||||
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
|
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
|
||||||
„nie da się odszyfrować" — więc pilnujemy tego testem."""
|
„nie da się odszyfrować" — więc pilnujemy tego testem."""
|
||||||
root = pathlib.Path(__file__).resolve().parents[3]
|
root = pathlib.Path(__file__).resolve().parents[3]
|
||||||
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
|
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
|
||||||
for svc in ("presentation", "logic", "data")}
|
for svc in ("presentation", "logic", "data", "render")}
|
||||||
missing = [svc for svc, path in copies.items() if not path.is_file()]
|
missing = [svc for svc, path in copies.items() if not path.is_file()]
|
||||||
assert not missing, f"brak modułu w warstwach: {missing}"
|
assert not missing, f"brak modułu w warstwach: {missing}"
|
||||||
contents = {svc: path.read_bytes() for svc, path in copies.items()}
|
contents = {svc: path.read_bytes() for svc, path in copies.items()}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
|
||||||
|
|
||||||
|
Rysujemy po stronie serwera, bez zależności zewnętrznych (spójne z CSP): czysty
|
||||||
|
string SVG wstawiany do strony. Gotowe: pierścień znaków, podział na domy, osie
|
||||||
|
oraz OBIEKTY (glify, stopień w znaku, retrogradacja). Linie aspektów w kolejnym
|
||||||
|
kroku.
|
||||||
|
|
||||||
|
Geometria (konwencja astrologiczna):
|
||||||
|
* Ascendent po LEWEJ (godzina 9), długość ekliptyczna rośnie PRZECIWNIE do
|
||||||
|
ruchu wskazówek zegara — jak na niebie i w każdym programie.
|
||||||
|
* Punkt o długości λ trafia na kąt φ = 180° − (λ − Asc). Zakotwiczenie:
|
||||||
|
λ=Asc → lewo, λ=Asc+90° → dół, λ=Asc+270° (≈MC) → góra. W SVG y rośnie w dół,
|
||||||
|
co ta formuła już uwzględnia (sin dodatni = w dół).
|
||||||
|
|
||||||
|
Dane bierzemy z wyniku /chart/positions (warstwa logiki): `angles` (z długością
|
||||||
|
`decimal`), `cusps` (z `decimal` — dołożone pod ten widok) i `sign_glyphs`
|
||||||
|
(pierścień 12 znaków z glifami — LOG-22). Prezentacja niczego nie liczy sama.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
# ── promienie (viewBox 470×470, środek 235,235) ─────────────────────────
|
||||||
|
# Płótno jest większe od koła CELOWO: margines poza obwodem mieści etykiety osi
|
||||||
|
# (AC/DC/MC/IC). Wcześniej siedziały wewnątrz, w pierścieniu obiektów, i zasłaniały
|
||||||
|
# glify w ciasnych skupiskach — widać było na renderze.
|
||||||
|
_CX = _CY = 235.0
|
||||||
|
_VIEW = 470
|
||||||
|
R_OUT = 210.0 # zewnętrzny okrąg
|
||||||
|
R_AXIS_LABEL = 222.0 # etykiety osi — w marginesie, poza kołem
|
||||||
|
R_ZOD = 178.0 # wewnętrzna krawędź pasa znaków
|
||||||
|
R_SIGN = 194.0 # promień glifów znaków (środek pasa)
|
||||||
|
R_TICK = 172.0 # koniec drobnych podziałek stopni (do wewnątrz od pasa)
|
||||||
|
R_HUB = 48.0 # centralne kółko — tu kończą się szprychy domów i osie
|
||||||
|
R_HNUM = 64.0 # promień numerów domów (tuż przy piaście)
|
||||||
|
R_PLANET = 150.0 # promień glifów obiektów
|
||||||
|
R_DEG = 130.0 # promień podpisu stopnia (pod glifem)
|
||||||
|
R_MARK = R_ZOD - 7.0 # koniec kreski wskazującej PRAWDZIWĄ pozycję na pasie
|
||||||
|
|
||||||
|
# Minimalny odstęp kątowy między glifami obiektów. O ciasnocie decyduje NIE glif,
|
||||||
|
# tylko podpis stopnia — leży bliżej środka (r=130), gdzie ten sam kąt to mniej
|
||||||
|
# pikseli: 8° daje tam ~18 px, a dwuznakowy podpis z ℞ zajmuje ~16 px. Przy 7°
|
||||||
|
# (pierwsze podejście) podpisy w skupiskach zlewały się — widać było na renderze.
|
||||||
|
# Bez rozsuwania w ogóle ciasne układy (u nas Merkury i Wenus 0,4°!) rysują się
|
||||||
|
# jeden na drugim.
|
||||||
|
MIN_SEP = 8.0
|
||||||
|
|
||||||
|
# muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji
|
||||||
|
_ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda
|
||||||
|
|
||||||
|
# ── motywy ───────────────────────────────────────────────────────────────
|
||||||
|
# „screen" korzysta ze zmiennych CSS strony, więc koło samo dostraja się do
|
||||||
|
# motywu aplikacji. „print" MUSI mieć konkretne kolory: SVG idzie wtedy do
|
||||||
|
# osobnego konwertera (rsvg → PDF, PRE-24), który o naszym arkuszu nic nie wie
|
||||||
|
# i zmiennych `var(--…)` nie rozwiąże — wyszłyby czarne albo żadne kreski.
|
||||||
|
# Przy okazji odwracamy kontrast: raport drukuje się na białym papierze.
|
||||||
|
_THEMES = {
|
||||||
|
"screen": {"line": "var(--line)", "ink": "var(--ink)", "muted": "var(--muted)",
|
||||||
|
"accent": "var(--accent)", "disc": "#12132a", "retro": "#ff9b6a",
|
||||||
|
"elements": _ELEMENT_COLOR},
|
||||||
|
"print": {"line": "#b9bcc9", "ink": "#111318", "muted": "#5d6270",
|
||||||
|
"accent": "#5a49c8", "disc": "#ffffff", "retro": "#b2452a",
|
||||||
|
# ciemniejsze warianty żywiołów — na białym tle jasne byłyby niewidoczne
|
||||||
|
"elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
|
||||||
|
phi = math.radians(180.0 - (lon - asc))
|
||||||
|
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
||||||
|
|
||||||
|
|
||||||
|
def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0,
|
||||||
|
opacity: float = 1.0) -> str:
|
||||||
|
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
||||||
|
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>')
|
||||||
|
|
||||||
|
|
||||||
|
# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS
|
||||||
|
# `.glyph`, ale w wariancie do druku SVG jedzie do SAMODZIELNEGO konwertera, który
|
||||||
|
# naszego arkusza nie widzi — wtedy trzeba wpisać font wprost do elementu, inaczej
|
||||||
|
# rysunek wychodzi BEZ symboli (konwerter bierze domyślny font, a ten ich nie ma).
|
||||||
|
GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, "
|
||||||
|
"DejaVu Sans, sans-serif")
|
||||||
|
|
||||||
|
|
||||||
|
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "",
|
||||||
|
font: str = "") -> str:
|
||||||
|
c = f' class="{cls}"' if cls else ""
|
||||||
|
f = f' font-family="{font}"' if font else ""
|
||||||
|
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
|
||||||
|
f' text-anchor="middle" dominant-baseline="central"{c}{f}>{escape(s)}</text>')
|
||||||
|
|
||||||
|
|
||||||
|
def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
|
||||||
|
"""Rozsuwa kąty tak, by sąsiedzi mieli co najmniej `min_sep`, zachowując
|
||||||
|
KOLEJNOŚĆ obiektów na kole i trzymając je blisko prawdziwych pozycji.
|
||||||
|
|
||||||
|
Metoda: relaksacja po okręgu — każdą zbyt ciasną parę rozpychamy po połowie
|
||||||
|
brakującego odstępu i powtarzamy, aż nic się nie rusza. Symetryczne pchnięcie
|
||||||
|
sprawia, że skupisko rozkłada się wokół swojego środka, zamiast dryfować
|
||||||
|
w jedną stronę.
|
||||||
|
|
||||||
|
Gdy obiektów jest tyle, że nie da się ich rozsunąć (n·min_sep ≥ 360°),
|
||||||
|
rozkładamy je równomiernie — lepiej równo niż w nieskończonej pętli.
|
||||||
|
"""
|
||||||
|
n = len(lons)
|
||||||
|
if n < 2:
|
||||||
|
return list(lons)
|
||||||
|
order = sorted(range(n), key=lambda i: lons[i] % 360.0)
|
||||||
|
a = [lons[i] % 360.0 for i in order]
|
||||||
|
|
||||||
|
if n * min_sep >= 360.0:
|
||||||
|
a = [(a[0] + i * 360.0 / n) % 360.0 for i in range(n)]
|
||||||
|
else:
|
||||||
|
# ROZWIJAMY kąty do osi monotonicznej. To nie kosmetyka: gdy liczyliśmy
|
||||||
|
# odstęp modulo 360, przesunięcie, które przerzuciło obiekt ZA sąsiada,
|
||||||
|
# dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że jest luzem,
|
||||||
|
# i kończył z dwoma glifami na sobie (Wenus lądowała na Księżycu).
|
||||||
|
# Na osi rozwiniętej ujemna luka zostaje ujemna i zawsze się ją wyłapie.
|
||||||
|
for i in range(1, n):
|
||||||
|
while a[i] < a[i - 1]:
|
||||||
|
a[i] += 360.0
|
||||||
|
|
||||||
|
for _ in range(passes):
|
||||||
|
moved = False
|
||||||
|
for i in range(n - 1):
|
||||||
|
gap = a[i + 1] - a[i]
|
||||||
|
if gap < min_sep - 1e-9:
|
||||||
|
push = (min_sep - gap) / 2.0
|
||||||
|
a[i] -= push
|
||||||
|
a[i + 1] += push
|
||||||
|
moved = True
|
||||||
|
closing = (a[0] + 360.0) - a[n - 1] # domknięcie okręgu
|
||||||
|
if closing < min_sep - 1e-9:
|
||||||
|
push = (min_sep - closing) / 2.0
|
||||||
|
a[n - 1] -= push
|
||||||
|
a[0] += push
|
||||||
|
moved = True
|
||||||
|
if not moved:
|
||||||
|
break
|
||||||
|
|
||||||
|
out = [0.0] * n
|
||||||
|
for slot, original in enumerate(order):
|
||||||
|
out[original] = a[slot] % 360.0
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _delta(a: float, b: float) -> float:
|
||||||
|
"""Najmniejsza różnica kątów w [0,180]."""
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def render(chart: dict, theme: str = "screen") -> str:
|
||||||
|
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
||||||
|
|
||||||
|
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
||||||
|
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
||||||
|
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
||||||
|
"""
|
||||||
|
T = _THEMES.get(theme, _THEMES["screen"])
|
||||||
|
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
||||||
|
glyph_font = GLYPH_FONT if theme == "print" else ""
|
||||||
|
angles = chart.get("angles")
|
||||||
|
cusps = chart.get("cusps")
|
||||||
|
signs = chart.get("sign_glyphs")
|
||||||
|
if not (angles and cusps and signs) or "decimal" not in (cusps[0] if cusps else {}):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
asc = float(angles["Asc"]["decimal"])
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
# tło i okręgi
|
||||||
|
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="{T["disc"]}" stroke="{T["line"]}" stroke-width="1.5"/>')
|
||||||
|
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
|
||||||
|
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
|
||||||
|
|
||||||
|
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
||||||
|
for deg in range(0, 360, 5):
|
||||||
|
r_in = R_TICK if deg % 10 else R_TICK - 4
|
||||||
|
x1, y1 = _pt(deg, asc, R_ZOD)
|
||||||
|
x2, y2 = _pt(deg, asc, r_in)
|
||||||
|
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
||||||
|
|
||||||
|
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
||||||
|
for i, sg in enumerate(signs):
|
||||||
|
b = 30.0 * i
|
||||||
|
x1, y1 = _pt(b, asc, R_ZOD)
|
||||||
|
x2, y2 = _pt(b, asc, R_OUT)
|
||||||
|
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
||||||
|
gx, gy = _pt(b + 15.0, asc, R_SIGN)
|
||||||
|
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
||||||
|
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
||||||
|
|
||||||
|
# szprychy domów (od pasa do piasty) + numery domów w środku każdego domu
|
||||||
|
n = len(cusps)
|
||||||
|
for i, c in enumerate(cusps):
|
||||||
|
lon = float(c["decimal"])
|
||||||
|
x1, y1 = _pt(lon, asc, R_ZOD)
|
||||||
|
x2, y2 = _pt(lon, asc, R_HUB)
|
||||||
|
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
||||||
|
nxt = float(cusps[(i + 1) % n]["decimal"])
|
||||||
|
span = (nxt - lon) % 360.0 or 360.0
|
||||||
|
mid = lon + span / 2.0
|
||||||
|
hx, hy = _pt(mid, asc, R_HNUM)
|
||||||
|
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
||||||
|
|
||||||
|
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
||||||
|
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
||||||
|
lon_a = float(angles[a]["decimal"])
|
||||||
|
ax, ay = _pt(lon_a, asc, R_ZOD)
|
||||||
|
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
|
||||||
|
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
||||||
|
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
||||||
|
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
|
||||||
|
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
|
||||||
|
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
||||||
|
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
||||||
|
|
||||||
|
# ── obiekty: glify na pozycjach, stopień w znaku, retrogradacja ─────
|
||||||
|
# Glify rozsuwamy, ale kreska na pasie zostaje w PRAWDZIWEJ pozycji —
|
||||||
|
# inaczej wykres kłamałby o tym, gdzie planeta faktycznie stoi. Gdy glif
|
||||||
|
# jest przesunięty, łączymy go z kreską cienką linią odniesienia.
|
||||||
|
objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
|
||||||
|
true_lons = [float(p["decimal"]) for p in objects]
|
||||||
|
draw_lons = spread(true_lons)
|
||||||
|
|
||||||
|
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
||||||
|
retro = obj.get("direction") == "Rx"
|
||||||
|
|
||||||
|
mx1, my1 = _pt(true_lon, asc, R_ZOD)
|
||||||
|
mx2, my2 = _pt(true_lon, asc, R_MARK)
|
||||||
|
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
||||||
|
|
||||||
|
if _delta(draw_lon, true_lon) > 0.4:
|
||||||
|
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
|
||||||
|
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
||||||
|
|
||||||
|
gx, gy = _pt(draw_lon, asc, R_PLANET)
|
||||||
|
parts.append(_text(gx, gy, obj.get("glyph") or "", size=16,
|
||||||
|
fill=T["ink"], cls="glyph", font=glyph_font))
|
||||||
|
|
||||||
|
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
||||||
|
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
||||||
|
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
||||||
|
dx, dy = _pt(draw_lon, asc, R_DEG)
|
||||||
|
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
||||||
|
parts.append(_text(dx, dy, label, size=8,
|
||||||
|
fill=T["retro"] if retro else T["muted"]))
|
||||||
|
|
||||||
|
body = "".join(parts)
|
||||||
|
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
|
||||||
|
f'aria-label="Kosmogram — koło horoskopowe" '
|
||||||
|
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Klient HTTP do usługi render (PRE-24).
|
||||||
|
|
||||||
|
Jedyny punkt styku prezentacji z usługą składającą PDF. Idzie po szyfrowanym
|
||||||
|
łączu, tak jak reszta ruchu wewnątrz klastra — z WŁASNYM, trzecim kluczem
|
||||||
|
(`LINK_KEY_PRESENTATION_RENDER`). Osobny klucz, bo przez to łącze leci CAŁY
|
||||||
|
raport: dane urodzeniowe i opisy z baz. Przejęcie go nie może otwierać łącza do
|
||||||
|
logiki ani do danych.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> dict[str, str]:
|
||||||
|
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
|
||||||
|
token = os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
return {"X-Astrololo-Token": token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza prezentacja↔render. Czytany przy każdym wywołaniu, bo
|
||||||
|
konfiguracja może się zmienić bez restartu procesu."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_RENDER)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
|
class RenderClient:
|
||||||
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
|
self.base_url = (base_url or settings.render_url).rstrip("/")
|
||||||
|
|
||||||
|
def pdf(self, report: dict) -> bytes:
|
||||||
|
"""Raport → bajty PDF. Timeout długi: XeLaTeX na obszernym tekście
|
||||||
|
potrzebuje chwili, a raport bywa na kilkadziesiąt stron."""
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 180.0)) as client:
|
||||||
|
return link_crypto.call(client, "POST", f"{self.base_url}/pdf",
|
||||||
|
payload=report, headers=_auth_headers(),
|
||||||
|
link=_link())
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
"""/health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||||
|
nie mają, a nie przechodzi tędy nic z raportu."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||||
|
r.raise_for_status()
|
||||||
|
return json.loads(r.content)
|
||||||
@@ -11,6 +11,8 @@ from dataclasses import dataclass, field
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Settings:
|
class Settings:
|
||||||
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
|
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
|
||||||
|
# Usługa składająca PDF (PRE-24) — osobny komponent, bo dźwiga TeX Live.
|
||||||
|
render_url: str = field(default_factory=lambda: os.getenv("RENDER_URL", "http://localhost:8004"))
|
||||||
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
|
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ CONTENT_TYPE = "application/vnd.astrololo.enc"
|
|||||||
|
|
||||||
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||||
|
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||||
|
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||||
|
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||||
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
REQUEST, RESPONSE = b"req", b"res"
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ def chart_form(request: Request):
|
|||||||
@app.post("/", response_class=HTMLResponse)
|
@app.post("/", response_class=HTMLResponse)
|
||||||
def chart_compute(
|
def chart_compute(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||||
date: str = Form(...),
|
date: str = Form(...),
|
||||||
time: str = Form(...),
|
time: str = Form(...),
|
||||||
tz_offset: float = Form(0.0),
|
tz_offset: float = Form(0.0),
|
||||||
@@ -81,7 +82,7 @@ def chart_compute(
|
|||||||
zodiac: str = Form("tropical"),
|
zodiac: str = Form("tropical"),
|
||||||
tables: bool = Form(False),
|
tables: bool = Form(False),
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
||||||
"zodiac": zodiac, "tables": tables}
|
"zodiac": zodiac, "tables": tables}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
@@ -92,6 +93,8 @@ def chart_compute(
|
|||||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
||||||
)
|
)
|
||||||
|
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||||
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -99,6 +102,112 @@ def chart_compute(
|
|||||||
return templates.TemplateResponse(request, "chart.html", ctx)
|
return templates.TemplateResponse(request, "chart.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
|
||||||
|
@app.get("/compile", response_class=HTMLResponse)
|
||||||
|
def compile_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "compile.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/compile", response_class=HTMLResponse)
|
||||||
|
def compile_build(
|
||||||
|
request: Request,
|
||||||
|
person: str = Form(""),
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
house_system: str = Form("whole_sign"),
|
||||||
|
zodiac: str = Form("tropical"),
|
||||||
|
):
|
||||||
|
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
||||||
|
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||||
|
|
||||||
|
Dlaczego horoskop liczymy ponownie, zamiast go zapamiętywać: to czysta funkcja
|
||||||
|
danych wejściowych — tanio ją powtórzyć, a odpada trzymanie w przeglądarce
|
||||||
|
dużego wyniku, który mógłby się rozjechać z aktualnym formularzem.
|
||||||
|
"""
|
||||||
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "house_system": house_system, "zodiac": zodiac}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
ctx["result"] = logic.positions(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
house_system=house_system, zodiac=zodiac,
|
||||||
|
)
|
||||||
|
from app import chartwheel
|
||||||
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||||
|
except (httpx.HTTPError,) as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "compile.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/compile/pdf")
|
||||||
|
def compile_pdf(payload: dict):
|
||||||
|
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
|
||||||
|
|
||||||
|
Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje)
|
||||||
|
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
|
||||||
|
zawierał dokładnie to, co widać na stronie.
|
||||||
|
|
||||||
|
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna
|
||||||
|
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from app.clients.render_client import RenderClient
|
||||||
|
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(
|
||||||
|
str(data.get("date") or ""), str(data.get("time") or ""),
|
||||||
|
float(data.get("tz_offset") or 0.0),
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||||||
|
|
||||||
|
wheel_svg = ""
|
||||||
|
try:
|
||||||
|
chart = logic.positions(
|
||||||
|
when_utc_iso=iso_utc,
|
||||||
|
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
|
||||||
|
house_system=str(data.get("house_system") or "whole_sign"),
|
||||||
|
zodiac=str(data.get("zodiac") or "tropical"),
|
||||||
|
)
|
||||||
|
from app import chartwheel
|
||||||
|
|
||||||
|
wheel_svg = chartwheel.render(chart, theme="print")
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
# Brak rysunku nie może zablokować raportu — tekst jest ważniejszy.
|
||||||
|
log_note = _logic_error(e)
|
||||||
|
chart, wheel_svg = None, ""
|
||||||
|
data = {**data, "wheel_error": log_note}
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"person": payload.get("person") or "",
|
||||||
|
"data": {**data, "moment_utc": label},
|
||||||
|
"wheel_svg": wheel_svg,
|
||||||
|
"natal": payload.get("natal") or {},
|
||||||
|
"predictions": payload.get("predictions") or [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
pdf = RenderClient().pdf(report)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
return Response(content=pdf, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
|
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
|
||||||
@app.get("/significators", response_class=HTMLResponse)
|
@app.get("/significators", response_class=HTMLResponse)
|
||||||
def significators_form(request: Request):
|
def significators_form(request: Request):
|
||||||
@@ -135,6 +244,7 @@ def interpret_form(request: Request):
|
|||||||
@app.post("/interpret", response_class=HTMLResponse)
|
@app.post("/interpret", response_class=HTMLResponse)
|
||||||
def interpret_run(
|
def interpret_run(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||||
date: str = Form(...),
|
date: str = Form(...),
|
||||||
time: str = Form(...),
|
time: str = Form(...),
|
||||||
tz_offset: float = Form(0.0),
|
tz_offset: float = Form(0.0),
|
||||||
@@ -146,7 +256,7 @@ def interpret_run(
|
|||||||
llm_provider: str = Form("local"),
|
llm_provider: str = Form("local"),
|
||||||
llm_model: str = Form(""),
|
llm_model: str = Form(""),
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
@@ -186,6 +296,7 @@ def timeline_form(request: Request):
|
|||||||
@app.post("/timeline", response_class=HTMLResponse)
|
@app.post("/timeline", response_class=HTMLResponse)
|
||||||
def timeline_run(
|
def timeline_run(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||||
date: str = Form(...),
|
date: str = Form(...),
|
||||||
time: str = Form(...),
|
time: str = Form(...),
|
||||||
tz_offset: float = Form(0.0),
|
tz_offset: float = Form(0.0),
|
||||||
@@ -198,7 +309,7 @@ def timeline_run(
|
|||||||
llm_provider: str = Form("local"),
|
llm_provider: str = Form("local"),
|
||||||
llm_model: str = Form(""),
|
llm_model: str = Form(""),
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Zakładka „Skompiluj" — składanie raportu (PRE-23).
|
||||||
|
//
|
||||||
|
// Raport ma trzy kawałki liczone w różnych miejscach:
|
||||||
|
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
|
||||||
|
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
|
||||||
|
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
|
||||||
|
//
|
||||||
|
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
|
||||||
|
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
|
||||||
|
// localStorage — dzięki temu format danych ma JEDNEGO właściciela.
|
||||||
|
//
|
||||||
|
// Pokazujemy też, CZEGO BRAKUJE. Bez tego użytkownik złożyłby niekompletny raport
|
||||||
|
// i dowiedział się o tym dopiero po otwarciu PDF-a.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNatal() {
|
||||||
|
return (window.astrololoNatal && window.astrololoNatal.read()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPredictions() {
|
||||||
|
return (window.astrololoPredictions && window.astrololoPredictions.read()) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(ok, label, hint) {
|
||||||
|
return '<li class="' + (ok ? 'rdy-ok' : 'rdy-missing') + '">' +
|
||||||
|
(ok ? '✓ ' : '✗ ') + esc(label) +
|
||||||
|
(ok ? '' : ' <span class="muted small">— ' + esc(hint) + '</span>') + '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReadiness(hasChart) {
|
||||||
|
var host = document.getElementById('readiness');
|
||||||
|
if (!host) return;
|
||||||
|
var natal = readNatal();
|
||||||
|
var preds = readPredictions();
|
||||||
|
var parts = [
|
||||||
|
item(hasChart, 'Policzony horoskop', 'wypełnij dane i kliknij „Złóż podsumowanie"'),
|
||||||
|
item(!!natal, 'Interpretacja natalna',
|
||||||
|
'wygeneruj ją na zakładce Interpretacje — zapamięta się sama'),
|
||||||
|
item(preds.length > 0,
|
||||||
|
'Predykcje okresowe' + (preds.length ? ' (' + preds.length + ')' : ''),
|
||||||
|
'policz horoskop na wybrany okres w Kalendarzu')
|
||||||
|
];
|
||||||
|
host.innerHTML = '<div class="meta">Materiał do raportu</div><ul class="readiness">' +
|
||||||
|
parts.join('') + '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNatal() {
|
||||||
|
var host = document.getElementById('reportNatal');
|
||||||
|
if (!host) return;
|
||||||
|
var natal = readNatal();
|
||||||
|
if (!natal) {
|
||||||
|
host.innerHTML = '<div class="meta">Interpretacja natalna</div>' +
|
||||||
|
'<p class="muted small">Brak — wygeneruj ją na zakładce Interpretacje.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.innerHTML = '<div class="meta">Interpretacja natalna ' +
|
||||||
|
'<span class="muted small">· zapamiętana ' + esc(String(natal.saved_at).slice(0, 10)) +
|
||||||
|
'</span></div><div class="report-text">' + esc(natal.text) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPredictions() {
|
||||||
|
var host = document.getElementById('reportPredictions');
|
||||||
|
if (!host) return;
|
||||||
|
var preds = readPredictions();
|
||||||
|
if (!preds.length) {
|
||||||
|
host.innerHTML = '<div class="meta">Predykcje okresowe</div>' +
|
||||||
|
'<p class="muted small">Brak — policz horoskop na wybrany okres w Kalendarzu.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var blocks = preds.map(function (p) {
|
||||||
|
return '<div class="meta">' + esc(p.from_date) + ' – ' + esc(p.to_date) + '</div>' +
|
||||||
|
'<div class="report-text">' + esc(p.text) + '</div>';
|
||||||
|
}).join('');
|
||||||
|
host.innerHTML = '<div class="meta">Predykcje okresowe (' + preds.length + ')</div>' + blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState !== 'loading') fn();
|
||||||
|
else document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF (PRE-24) ────────────────────────────────────────────────────
|
||||||
|
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
|
||||||
|
// do serwera, który dokłada kosmogram i woła usługę render.
|
||||||
|
function formData() {
|
||||||
|
function v(name) {
|
||||||
|
var el = document.querySelector('[name=' + name + ']');
|
||||||
|
return el ? el.value : '';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: v('date'), time: v('time'), tz_offset: v('tz_offset'),
|
||||||
|
lat: v('lat'), lon: v('lon'),
|
||||||
|
house_system: v('house_system'), zodiac: v('zodiac'),
|
||||||
|
place: (document.querySelector('#geoSearch') || {}).value || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(blob) {
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'raport.pdf';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
// Zwalniamy obiekt dopiero po kliknięciu — wcześniej przeglądarka nie zdąży
|
||||||
|
// rozpocząć pobierania.
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPdf(btn, note) {
|
||||||
|
var person = (document.querySelector('[name=person]') || {}).value || '';
|
||||||
|
var payload = {
|
||||||
|
person: person,
|
||||||
|
data: formData(),
|
||||||
|
natal: readNatal() || {},
|
||||||
|
predictions: readPredictions()
|
||||||
|
};
|
||||||
|
btn.disabled = true;
|
||||||
|
note.textContent = 'Składam PDF — LaTeX potrzebuje chwili przy obszernym tekście…';
|
||||||
|
fetch('/compile/pdf', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.json().catch(function () { return {}; }).then(function (d) {
|
||||||
|
throw new Error(d.detail || ('HTTP ' + r.status));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.blob();
|
||||||
|
}).then(function (blob) {
|
||||||
|
download(blob);
|
||||||
|
note.textContent = 'Gotowe — PDF pobrany.';
|
||||||
|
}).catch(function (e) {
|
||||||
|
note.textContent = 'Nie udało się złożyć PDF-a: ' + e.message;
|
||||||
|
}).finally(function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
if (!document.getElementById('readiness')) return; // nie ta zakładka
|
||||||
|
// Horoskop jest w dokumencie tylko po wysłaniu formularza.
|
||||||
|
renderReadiness(!!document.getElementById('reportPerson'));
|
||||||
|
renderNatal();
|
||||||
|
renderPredictions();
|
||||||
|
|
||||||
|
var btn = document.getElementById('pdfBtn');
|
||||||
|
var note = document.getElementById('pdfNote');
|
||||||
|
if (btn && note) btn.addEventListener('click', function () { buildPdf(btn, note); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Wspólne dane formularza między zakładkami (PRE-21).
|
||||||
|
//
|
||||||
|
// Problem: każdą zakładkę (Horoskop / Interpretacje / Kalendarz) wypełniało się
|
||||||
|
// od nowa — te same imię, data, godzina, strefa i miejsce. Teraz raz wpisane
|
||||||
|
// dane wędrują za użytkownikiem.
|
||||||
|
//
|
||||||
|
// Dlaczego localStorage, a nie sesja na serwerze: serwer zostaje BEZSTANOWY
|
||||||
|
// (żadnego magazynu sesji, żadnych danych urodzeniowych trzymanych po stronie
|
||||||
|
// usługi), a przy okazji dane synchronizują się też między osobnymi kartami
|
||||||
|
// przeglądarki — zdarzenie `storage` daje to za darmo.
|
||||||
|
//
|
||||||
|
// UWAGA na kolejność: ten skrypt ładujemy w <head> z `defer`, więc wykonuje się
|
||||||
|
// PO sparsowaniu dokumentu, ale PRZED geo.js (skrypty defer idą w kolejności
|
||||||
|
// dokumentu). Dzięki temu mapa startuje już z odtworzonymi współrzędnymi i nie
|
||||||
|
// trzeba jej potem przestawiać.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var KEY = 'astrololo.form.v1';
|
||||||
|
|
||||||
|
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
|
||||||
|
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
|
||||||
|
var FIELDS = [
|
||||||
|
{ key: 'person', sel: 'input[name=person]' },
|
||||||
|
{ key: 'date', sel: 'input[name=date]' },
|
||||||
|
{ key: 'time', sel: 'input[name=time]' },
|
||||||
|
{ key: 'tz_offset', sel: 'input[name=tz_offset]' },
|
||||||
|
{ key: 'lat', sel: 'input[name=lat]' },
|
||||||
|
{ key: 'lon', sel: 'input[name=lon]' },
|
||||||
|
{ key: 'house_system', sel: 'select[name=house_system]' },
|
||||||
|
{ key: 'zodiac', sel: 'select[name=zodiac]' },
|
||||||
|
{ key: 'place', sel: '#geoSearch' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(KEY) || '{}') || {};
|
||||||
|
} catch (e) {
|
||||||
|
return {}; // uszkodzony wpis nie może zablokować strony
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(state) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(state));
|
||||||
|
} catch (e) {
|
||||||
|
/* tryb prywatny albo brak miejsca — trudno, formularz ma działać dalej */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(state) {
|
||||||
|
FIELDS.forEach(function (f) {
|
||||||
|
var el = document.querySelector(f.sel);
|
||||||
|
if (!el) return;
|
||||||
|
var v = state[f.key];
|
||||||
|
if (v === undefined || v === null || v === '') return;
|
||||||
|
if (el.value !== v) el.value = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect() {
|
||||||
|
var state = load();
|
||||||
|
FIELDS.forEach(function (f) {
|
||||||
|
var el = document.querySelector(f.sel);
|
||||||
|
if (!el) return; // pola nieobecnego na tej zakładce nie kasujemy
|
||||||
|
if (el.value === '' || el.value === null) return;
|
||||||
|
state[f.key] = el.value;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) odtwórz to, co użytkownik wpisał gdzie indziej
|
||||||
|
apply(load());
|
||||||
|
|
||||||
|
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list i kalendarzy)
|
||||||
|
FIELDS.forEach(function (f) {
|
||||||
|
var el = document.querySelector(f.sel);
|
||||||
|
if (!el) return;
|
||||||
|
['input', 'change'].forEach(function (ev) {
|
||||||
|
el.addEventListener(ev, function () { save(collect()); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) zapisz stan po wysłaniu formularza — po przeładowaniu strony pola mają
|
||||||
|
// pokazywać to, co faktycznie policzyliśmy
|
||||||
|
document.addEventListener('submit', function () { save(collect()); }, true);
|
||||||
|
|
||||||
|
// 4) druga otwarta karta zmieniła dane -> dociągnij je tutaj na żywo
|
||||||
|
window.addEventListener('storage', function (e) {
|
||||||
|
if (e.key === KEY) apply(load());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) geo.js ustawia lat/lon z mapy poza polami formularza — po takiej zmianie
|
||||||
|
// też chcemy zapamiętać (zdarzenie leci z „Tu i teraz" oraz z mapy)
|
||||||
|
document.addEventListener('astrololo:coords', function () {
|
||||||
|
setTimeout(function () { save(collect()); }, 0);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -38,18 +38,32 @@
|
|||||||
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
|
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
|
||||||
setTimeout(function () { map.invalidateSize(); }, 60);
|
setTimeout(function () { map.invalidateSize(); }, 60);
|
||||||
|
|
||||||
|
// Ustawienie `.value` z kodu NIE wywołuje zdarzeń, więc synchronizacja
|
||||||
|
// między zakładkami (formsync.js) przegapiłaby zmiany z mapy. Zgłaszamy je
|
||||||
|
// jawnie przez `change`.
|
||||||
|
function setVal(el, value) {
|
||||||
|
if (!el) return;
|
||||||
|
el.value = value;
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
function setCoords(lat, lon, fromMap) {
|
function setCoords(lat, lon, fromMap) {
|
||||||
latEl.value = lat.toFixed(4);
|
setVal(latEl, lat.toFixed(4));
|
||||||
lonEl.value = lon.toFixed(4);
|
setVal(lonEl, lon.toFixed(4));
|
||||||
marker.setLatLng([lat, lon]);
|
marker.setLatLng([lat, lon]);
|
||||||
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
|
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
|
||||||
if (fromMap) reverseName(lat, lon);
|
if (fromMap) reverseName(lat, lon);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reverseName(lat, lon) {
|
function reverseName(lat, lon) {
|
||||||
|
// Czyścimy pole OD RAZU. Dopóki nie znamy nazwy nowego punktu, stara jest
|
||||||
|
// po prostu nieprawdziwa — pokazywałaby inne miejsce niż współrzędne obok.
|
||||||
|
// Dotyczy też sytuacji, gdy /reverse nie odpowie (brak sieci): lepiej puste
|
||||||
|
// pole i poprawne współrzędne niż nazwa, która kłamie.
|
||||||
|
if (searchEl) setVal(searchEl, '');
|
||||||
fetch('/reverse?lat=' + lat + '&lon=' + lon)
|
fetch('/reverse?lat=' + lat + '&lon=' + lon)
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
.then(function (d) { if (d && d.name) searchEl.value = d.name; })
|
.then(function (d) { if (d && d.name && searchEl) setVal(searchEl, d.name); })
|
||||||
.catch(function () {});
|
.catch(function () {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +82,7 @@
|
|||||||
li.textContent = it.name;
|
li.textContent = it.name;
|
||||||
li.addEventListener('click', function () {
|
li.addEventListener('click', function () {
|
||||||
setCoords(it.lat, it.lon, false);
|
setCoords(it.lat, it.lon, false);
|
||||||
searchEl.value = it.name;
|
setVal(searchEl, it.name);
|
||||||
resultsEl.hidden = true;
|
resultsEl.hidden = true;
|
||||||
});
|
});
|
||||||
resultsEl.appendChild(li);
|
resultsEl.appendChild(li);
|
||||||
@@ -108,9 +122,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- synchronizacja z „Tu i teraz" ----
|
// ---- synchronizacja z „Tu i teraz" ----
|
||||||
|
// Poza przesunięciem pineski MUSIMY odświeżyć pole tekstowe: po „Tu i teraz"
|
||||||
|
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno
|
||||||
|
// miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
|
||||||
|
// na który faktycznie ustawiła się mapa.
|
||||||
document.addEventListener('astrololo:coords', function () {
|
document.addEventListener('astrololo:coords', function () {
|
||||||
marker.setLatLng([curLat(), curLon()]);
|
marker.setLatLng([curLat(), curLon()]);
|
||||||
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
||||||
|
reverseName(curLat(), curLon());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Zapamiętana interpretacja natalna (PRE-23).
|
||||||
|
//
|
||||||
|
// Odpowiednik predictions.js, tylko że natalna jest JEDNA — dotyczy momentu
|
||||||
|
// urodzenia, nie wybranego okresu. Ponowne wygenerowanie podmienia poprzednią.
|
||||||
|
//
|
||||||
|
// Po co magazyn: zakładka „Skompiluj" składa raport z trzech kawałków liczonych
|
||||||
|
// w różnych miejscach (horoskop, interpretacja natalna, predykcje okresowe).
|
||||||
|
// Bez zapamiętania trzeba by je generować od nowa na jednej stronie.
|
||||||
|
//
|
||||||
|
// localStorage, tak jak reszta stanu (PRE-21/22) — serwer zostaje bezstanowy.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var KEY = 'astrololo.natal.v1';
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
try {
|
||||||
|
var v = JSON.parse(localStorage.getItem(KEY) || 'null');
|
||||||
|
return (v && typeof v === 'object' && v.text) ? v : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null; // uszkodzony wpis nie może zablokować strony
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
try { localStorage.removeItem(KEY); } catch (e) { /* trudno */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function val(sel) {
|
||||||
|
var el = document.querySelector(sel);
|
||||||
|
return el ? el.value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
var text = val('#horoscopeText');
|
||||||
|
if (!text) return false; // nie ma czego zapamiętać
|
||||||
|
var entry = {
|
||||||
|
text: text,
|
||||||
|
person: val('input[name=person]'),
|
||||||
|
date: val('input[name=date]'),
|
||||||
|
time: val('input[name=time]'),
|
||||||
|
tz_offset: val('input[name=tz_offset]'),
|
||||||
|
lat: val('input[name=lat]'),
|
||||||
|
lon: val('input[name=lon]'),
|
||||||
|
saved_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(entry));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false; // przepełniony magazyn — zgłaszamy wyżej
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function note(msg) {
|
||||||
|
var el = document.getElementById('natalNote');
|
||||||
|
if (el) el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCurrent() {
|
||||||
|
var text = val('#horoscopeText');
|
||||||
|
if (!text) return;
|
||||||
|
if (save()) {
|
||||||
|
note('Interpretacja natalna zapamiętana — trafi do raportu (zakładka Skompiluj).');
|
||||||
|
} else {
|
||||||
|
// Interpretacje bywają długie; cicha strata byłaby najgorsza.
|
||||||
|
note('Nie udało się zapamiętać — magazyn przeglądarki pełny. '
|
||||||
|
+ 'Usuń starsze predykcje i spróbuj ponownie.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState !== 'loading') fn();
|
||||||
|
else document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
if (!document.getElementById('natalNote')) return; // nie ta zakładka
|
||||||
|
|
||||||
|
// 1) interpretacja policzona przez okno postępu (strumień)
|
||||||
|
document.addEventListener('astrololo:horoscope', function (e) {
|
||||||
|
if (!e.detail || e.detail.profile === 'natal') saveCurrent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
|
||||||
|
// POST. Zapis nadpisuje jeden slot, więc odświeżenie niczego nie mnoży.
|
||||||
|
if (document.querySelector('#horoscopeText')) saveCurrent();
|
||||||
|
|
||||||
|
var status = read();
|
||||||
|
if (status) {
|
||||||
|
note('W raporcie jest zapamiętana interpretacja natalna z '
|
||||||
|
+ String(status.saved_at).slice(0, 10) + '.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.astrololoNatal = { read: read, clear: clear, key: KEY };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Zapamiętane predykcje okresowe (PRE-22).
|
||||||
|
//
|
||||||
|
// Kalendarz pozwala policzyć horoskop na wybrany okres. Partnerzy chcą kilku
|
||||||
|
// okresów naraz — i żeby wszystkie trafiły potem do raportu (zakładka
|
||||||
|
// „Skompiluj", PRE-23/24). Tu jest magazyn i lista.
|
||||||
|
//
|
||||||
|
// Trzymamy w localStorage, tak jak wspólne dane formularza (PRE-21): serwer
|
||||||
|
// zostaje bezstanowy, żadne dane urodzeniowe ani treści z baz nie lądują po
|
||||||
|
// stronie usługi. Zakładka „Skompiluj" odczyta to samo miejsce.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var KEY = 'astrololo.predictions.v1';
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
try {
|
||||||
|
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||||
|
return Array.isArray(v) ? v : [];
|
||||||
|
} catch (e) {
|
||||||
|
return []; // uszkodzony wpis nie może zablokować strony
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(list) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(list));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false; // najczęściej przepełniony magazyn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function byPeriod(a, b) {
|
||||||
|
return String(a.from_date).localeCompare(String(b.from_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klucz tożsamości to OKRES. Ponowne policzenie tego samego zakresu ma
|
||||||
|
// podmienić wpis, a nie dokładać duplikat — inaczej lista puchłaby przy
|
||||||
|
// każdej próbie z innym modelem.
|
||||||
|
function upsert(entry) {
|
||||||
|
var list = read().filter(function (p) {
|
||||||
|
return !(p.from_date === entry.from_date && p.to_date === entry.to_date);
|
||||||
|
});
|
||||||
|
list.push(entry);
|
||||||
|
list.sort(byPeriod);
|
||||||
|
return write(list) ? list : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id) {
|
||||||
|
var list = read().filter(function (p) { return p.id !== id; });
|
||||||
|
write(list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function val(sel) {
|
||||||
|
var el = document.querySelector(sel);
|
||||||
|
return el ? el.value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wyciąga świeżo policzony horoskop okresowy ze strony. */
|
||||||
|
function capture() {
|
||||||
|
var text = val('#horoscopeText');
|
||||||
|
var from = val('input[name=from_date]');
|
||||||
|
var to = val('input[name=to_date]');
|
||||||
|
if (!text || !from || !to) return null;
|
||||||
|
return {
|
||||||
|
id: from + '..' + to,
|
||||||
|
from_date: from,
|
||||||
|
to_date: to,
|
||||||
|
person: val('input[name=person]'),
|
||||||
|
text: text,
|
||||||
|
saved_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
return String(iso || '').slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var host = document.getElementById('savedPredictions');
|
||||||
|
if (!host) return;
|
||||||
|
var list = read();
|
||||||
|
if (!list.length) {
|
||||||
|
host.innerHTML = '<p class="muted small">Brak zapamiętanych predykcji. ' +
|
||||||
|
'Policz horoskop na wybrany okres — zapamięta się sam i trafi do raportu.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rows = list.map(function (p) {
|
||||||
|
var chars = (p.text || '').length;
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="nowrap">' + fmtDate(p.from_date) + ' – ' + fmtDate(p.to_date) + '</td>' +
|
||||||
|
'<td class="muted small">' + fmtDate(p.saved_at) + '</td>' +
|
||||||
|
'<td class="muted small">' + chars.toLocaleString('pl-PL') + ' znaków</td>' +
|
||||||
|
'<td><button type="button" class="ghost" data-drop="' +
|
||||||
|
String(p.id).replace(/"/g, '"') + '">Usuń</button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
host.innerHTML =
|
||||||
|
'<div class="meta">Zapamiętane predykcje (' + list.length + ') — wejdą do raportu</div>' +
|
||||||
|
'<table class="angles"><thead><tr><th>Okres</th><th>Zapisano</th>' +
|
||||||
|
'<th>Objętość</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(host.querySelectorAll('[data-drop]'), function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
remove(btn.getAttribute('data-drop'));
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCurrent() {
|
||||||
|
var entry = capture();
|
||||||
|
if (!entry) return;
|
||||||
|
var note = document.getElementById('predictionsNote');
|
||||||
|
if (upsert(entry)) {
|
||||||
|
if (note) note.textContent = 'Zapamiętano predykcję ' +
|
||||||
|
entry.from_date + ' – ' + entry.to_date + '.';
|
||||||
|
} else if (note) {
|
||||||
|
// Horoskopy bywają długie; gdy magazyn się przepełni, trzeba powiedzieć
|
||||||
|
// wprost, zamiast po cichu zgubić wynik.
|
||||||
|
note.textContent = 'Nie udało się zapamiętać — magazyn przeglądarki pełny. ' +
|
||||||
|
'Usuń starsze predykcje i spróbuj ponownie.';
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState !== 'loading') fn();
|
||||||
|
else document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
if (!document.getElementById('savedPredictions')) return; // nie ta zakładka
|
||||||
|
render();
|
||||||
|
|
||||||
|
// 1) horoskop policzony przez okno postępu (strumień)
|
||||||
|
document.addEventListener('astrololo:horoscope', function (e) {
|
||||||
|
if (!e.detail || e.detail.profile === 'period') saveCurrent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
|
||||||
|
// POST. Zapis jest idempotentny (klucz = okres), więc odświeżenie strony
|
||||||
|
// niczego nie duplikuje.
|
||||||
|
if (document.querySelector('#horoscopeText')) saveCurrent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
|
||||||
|
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
|
||||||
|
})();
|
||||||
@@ -113,6 +113,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
overlay.hidden = true;
|
overlay.hidden = true;
|
||||||
const host = document.getElementById('promptResult');
|
const host = document.getElementById('promptResult');
|
||||||
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
// Gotowy horoskop ogłaszamy zdarzeniem, zamiast zapisywać go tutaj:
|
||||||
|
// to okno postępu, a nie magazyn. Nasłuchuje predictions.js (PRE-22).
|
||||||
|
document.dispatchEvent(new CustomEvent('astrololo:horoscope', {
|
||||||
|
detail: { profile: profile }
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
|
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
|
||||||
|
|||||||
@@ -32,6 +32,42 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
|||||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||||
|
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||||
|
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||||
|
2) font-variant-emoji:text — nowsza własność CSS,
|
||||||
|
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||||
|
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||||
|
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||||
|
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||||
|
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||||
|
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||||
|
"DejaVu Sans", sans-serif; }
|
||||||
|
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||||
|
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
||||||
|
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
||||||
|
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
||||||
|
.wheel-fig figcaption { margin-top: .4rem; }
|
||||||
|
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||||
|
svg.wheel text.glyph { font-size: inherit; }
|
||||||
|
|
||||||
|
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
||||||
|
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
||||||
|
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||||
|
zostać na wierzchu. */
|
||||||
|
.wheel-fig svg.wheel { cursor: zoom-in; }
|
||||||
|
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||||
|
.wheel-fig.zoomed {
|
||||||
|
position: fixed; inset: 0; z-index: 1100; margin: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: .5rem; padding: 1rem; background: var(--bg);
|
||||||
|
}
|
||||||
|
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
|
||||||
|
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
|
||||||
|
.wheel-fig.zoomed svg.wheel {
|
||||||
|
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
||||||
|
}
|
||||||
|
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
|
||||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
.row { display: flex; gap: .5rem; }
|
.row { display: flex; gap: .5rem; }
|
||||||
.row input[type=text] { flex: 1; }
|
.row input[type=text] { flex: 1; }
|
||||||
@@ -116,3 +152,15 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|||||||
.progress-log li.log-ok { color: #7fd18b; }
|
.progress-log li.log-ok { color: #7fd18b; }
|
||||||
.progress-log li.log-warn { color: #e0c060; }
|
.progress-log li.log-warn { color: #e0c060; }
|
||||||
.progress-log li.log-err { color: #ef6b6b; }
|
.progress-log li.log-err { color: #ef6b6b; }
|
||||||
|
|
||||||
|
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
|
||||||
|
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
|
||||||
|
.readiness li { padding: .2rem 0; }
|
||||||
|
.readiness .rdy-ok { color: #7fd18b; }
|
||||||
|
.readiness .rdy-missing { color: #e0c060; }
|
||||||
|
.report-head { margin: 1.5rem 0 .5rem; }
|
||||||
|
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
||||||
|
/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
|
||||||
|
bez przepisywania ich na HTML. */
|
||||||
|
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Powiększanie kosmogramu na pełne okno (PRE-25).
|
||||||
|
//
|
||||||
|
// Koło rysujemy w kolumnie tekstu, więc na małym ekranie glify i stopnie robią
|
||||||
|
// się nieczytelne. Kliknięcie rozciąga wykres na całe okno, ponowne wraca do
|
||||||
|
// strony. SVG skaluje się bez utraty jakości, więc nie potrzeba drugiej wersji
|
||||||
|
// rysunku ani żadnej biblioteki.
|
||||||
|
//
|
||||||
|
// Podpięte globalnie i osłonięte sprawdzeniem, czy koło w ogóle jest na stronie
|
||||||
|
// — dzięki temu zadziała też na przyszłej zakładce „Skompiluj", bez zmian tutaj.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var ZOOMED = 'zoomed';
|
||||||
|
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState !== 'loading') fn();
|
||||||
|
else document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
var figures = document.querySelectorAll('.wheel-fig');
|
||||||
|
if (!figures.length) return;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(figures, function (fig) {
|
||||||
|
var svg = fig.querySelector('svg.wheel');
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
// Klawiatura ma działać tak samo jak mysz — to nie jest ozdobnik, tylko
|
||||||
|
// jedyny sposób na obejrzenie szczegółów wykresu.
|
||||||
|
svg.setAttribute('tabindex', '0');
|
||||||
|
svg.setAttribute('role', 'button');
|
||||||
|
svg.setAttribute('aria-label', 'Kosmogram — kliknij, aby powiększyć na całe okno');
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
var on = fig.classList.toggle(ZOOMED);
|
||||||
|
// Blokujemy przewijanie strony pod spodem: inaczej scroll „przecieka"
|
||||||
|
// na treść, której i tak nie widać.
|
||||||
|
document.body.classList.toggle('wheel-zoom-open', on);
|
||||||
|
svg.setAttribute('aria-label', on
|
||||||
|
? 'Kosmogram powiększony — kliknij lub naciśnij Esc, aby wrócić'
|
||||||
|
: 'Kosmogram — kliknij, aby powiększyć na całe okno');
|
||||||
|
if (on) svg.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.addEventListener('click', toggle);
|
||||||
|
svg.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Klik w tło (poza samym rysunkiem) też zamyka — naturalne dla nakładki.
|
||||||
|
fig.addEventListener('click', function (e) {
|
||||||
|
if (e.target === fig && fig.classList.contains(ZOOMED)) toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && fig.classList.contains(ZOOMED)) toggle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -5,6 +5,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>astrololo · {% block title %}{% endblock %}</title>
|
<title>astrololo · {% block title %}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
{# Wspólne dane formularza między zakładkami (PRE-21). W <head> z `defer`
|
||||||
|
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży
|
||||||
|
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu
|
||||||
|
we właściwym miejscu, bez przestawiania. #}
|
||||||
|
<script defer src="/static/formsync.js"></script>
|
||||||
|
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
|
||||||
|
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
|
||||||
|
<script defer src="/static/wheelzoom.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
@@ -15,6 +23,7 @@
|
|||||||
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
||||||
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
|
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
|
||||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||||
|
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||||
|
|
||||||
<form method="post" action="/">
|
<form method="post" action="/">
|
||||||
|
<div class="grid">
|
||||||
|
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
|
||||||
|
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
|
||||||
|
value="{{ form.person or '' }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Data
|
<label>Data
|
||||||
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
@@ -13,8 +19,9 @@
|
|||||||
<label>Godzina (lokalna)
|
<label>Godzina (lokalna)
|
||||||
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Strefa (offset h)
|
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
|
||||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
|
||||||
|
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -71,6 +78,13 @@
|
|||||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if wheel_svg %}
|
||||||
|
<figure class="wheel-fig">
|
||||||
|
{{ wheel_svg | safe }}
|
||||||
|
<figcaption class="muted small">Kosmogram — znaki, domy, osie i obiekty. Kreska na pasie wskazuje dokładną pozycję; glif bywa odsunięty, gdy obiekty stoją ciasno. Linie aspektów w kolejnym kroku.</figcaption>
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.angles %}
|
{% if result.angles %}
|
||||||
<table class="angles">
|
<table class="angles">
|
||||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||||
@@ -86,6 +100,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
|
||||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||||
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
|
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -93,6 +108,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for p in result.positions %}
|
{% for p in result.positions %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
|
||||||
<td>{{ p.name }}</td>
|
<td>{{ p.name }}</td>
|
||||||
<td>{{ p.sign }}</td>
|
<td>{{ p.sign }}</td>
|
||||||
<td class="mono">{{ p.in_sign }}</td>
|
<td class="mono">{{ p.in_sign }}</td>
|
||||||
@@ -140,7 +156,7 @@
|
|||||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in result.aspects %}
|
{% for a in result.aspects %}
|
||||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Skompiluj{% endblock %}
|
||||||
|
{% block nav_compile %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
|
||||||
|
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
|
||||||
|
|
||||||
|
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
|
||||||
|
kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
|
||||||
|
<div id="readiness"></div>
|
||||||
|
|
||||||
|
<form method="post" action="/compile">
|
||||||
|
<div class="grid">
|
||||||
|
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
|
||||||
|
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
|
||||||
|
value="{{ form.person or '' }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Data
|
||||||
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Godzina (lokalna)
|
||||||
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
|
||||||
|
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
|
||||||
|
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<label>Szerokość (lat, + N)
|
||||||
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Długość (lon, + E)
|
||||||
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>System domów
|
||||||
|
<select name="house_system">
|
||||||
|
{% set hs = form.house_system or 'whole_sign' %}
|
||||||
|
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
||||||
|
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
||||||
|
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Zodiak
|
||||||
|
<select name="zodiac">
|
||||||
|
{% set zd = form.zodiac or 'tropical' %}
|
||||||
|
<option value="tropical" {{ 'selected' if zd == 'tropical' else '' }}>Tropikalny</option>
|
||||||
|
<option value="sidereal_lahiri" {{ 'selected' if zd == 'sidereal_lahiri' else '' }}>Syderyczny (Lahiri)</option>
|
||||||
|
<option value="sidereal_fagan_bradley" {{ 'selected' if zd == 'sidereal_fagan_bradley' else '' }}>Syderyczny (Fagan-Bradley)</option>
|
||||||
|
<option value="sidereal_krishnamurti" {{ 'selected' if zd == 'sidereal_krishnamurti' else '' }}>Syderyczny (Krishnamurti)</option>
|
||||||
|
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Złóż podsumowanie</button>
|
||||||
|
<button type="button" id="pdfBtn" class="ghost"
|
||||||
|
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
|
||||||
|
<span id="pdfNote" class="muted small"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
{# ── 1. Nagłówek: kto ──────────────────────────────────────────────── #}
|
||||||
|
<div class="report-head">
|
||||||
|
<h2 id="reportPerson">{{ form.person or 'Bez podanego imienia' }}</h2>
|
||||||
|
<p class="muted small">
|
||||||
|
{{ form.date }} {{ form.time }} · offset {{ form.tz_offset }} h względem GMT ·
|
||||||
|
{{ '%.4f'|format(form.lat|float) }}, {{ '%.4f'|format(form.lon|float) }}
|
||||||
|
{% if moment %}· {{ moment }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── 2. Kosmogram ─────────────────────────────────────────────────── #}
|
||||||
|
{% if wheel_svg %}
|
||||||
|
<figure class="wheel-fig">
|
||||||
|
{{ wheel_svg | safe }}
|
||||||
|
<figcaption class="muted small">Kosmogram — kliknij, aby powiększyć.</figcaption>
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
|
||||||
|
<div class="meta">Horoskop · silnik {{ result.engine }}
|
||||||
|
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
|
||||||
|
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Sym.</th><th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in result.positions %}
|
||||||
|
<tr>
|
||||||
|
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
|
||||||
|
<td>{{ p.name }}</td><td>{{ p.sign }}</td><td class="mono">{{ p.in_sign }}</td>
|
||||||
|
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||||
|
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if result.angles %}
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
|
||||||
|
{% set a = result.angles[key] %}
|
||||||
|
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
|
||||||
|
<div id="reportNatal"></div>
|
||||||
|
<div id="reportPredictions"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
|
||||||
|
każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
|
||||||
|
<script src="/static/natal.js"></script>
|
||||||
|
<script src="/static/predictions.js"></script>
|
||||||
|
<script src="/static/compile.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,6 +6,12 @@
|
|||||||
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||||
|
|
||||||
<form method="post" action="/interpret">
|
<form method="post" action="/interpret">
|
||||||
|
<div class="grid">
|
||||||
|
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
|
||||||
|
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
|
||||||
|
value="{{ form.person or '' }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Data
|
<label>Data
|
||||||
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
@@ -13,8 +19,9 @@
|
|||||||
<label>Godzina (lokalna)
|
<label>Godzina (lokalna)
|
||||||
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Strefa (offset h)
|
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
|
||||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
|
||||||
|
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
</label>
|
</label>
|
||||||
<label>Szerokość (lat)
|
<label>Szerokość (lat)
|
||||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
@@ -29,6 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
{% include "_prompt_block.html" %}
|
{% include "_prompt_block.html" %}
|
||||||
|
<p class="muted small" id="natalNote"></p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||||
@@ -92,4 +100,6 @@
|
|||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
<script src="/static/models.js"></script>
|
||||||
<script src="/static/progress.js"></script>
|
<script src="/static/progress.js"></script>
|
||||||
|
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
|
||||||
|
<script src="/static/natal.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||||
|
|
||||||
<form method="post" action="/timeline">
|
<form method="post" action="/timeline">
|
||||||
|
<div class="grid">
|
||||||
|
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
|
||||||
|
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
|
||||||
|
value="{{ form.person or '' }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Data urodzenia
|
<label>Data urodzenia
|
||||||
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
@@ -13,8 +19,9 @@
|
|||||||
<label>Godzina (lokalna)
|
<label>Godzina (lokalna)
|
||||||
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Strefa (offset h)
|
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
|
||||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
|
||||||
|
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
</label>
|
</label>
|
||||||
<label>Szerokość (lat)
|
<label>Szerokość (lat)
|
||||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
@@ -78,8 +85,16 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Zapamiętane predykcje okresowe (PRE-22) — materiał dla zakładki „Skompiluj" #}
|
||||||
|
<section id="predictionsBox">
|
||||||
|
<div id="savedPredictions"></div>
|
||||||
|
<p class="muted small" id="predictionsNote"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
<script src="/static/models.js"></script>
|
||||||
<script src="/static/progress.js"></script>
|
<script src="/static/progress.js"></script>
|
||||||
|
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
|
||||||
|
<script src="/static/predictions.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
|
||||||
|
|
||||||
|
Sprawdzamy, że SVG jest POPRAWNY (parsuje się jako XML — inaczej wstawiony do
|
||||||
|
strony rozjedzie render) i że niesie to, co ma nieść: pierścień 12 znaków, cztery
|
||||||
|
osie, numery domów i obiekty. Geometria (Asc po lewej) oraz rozsuwanie ciasnych
|
||||||
|
skupisk sprawdzone liczbowo, nie na oko.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import xml.dom.minidom as minidom
|
||||||
|
|
||||||
|
from app import chartwheel
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_chart(asc=128.9, mc=5.0):
|
||||||
|
"""Minimalny wynik z warstwy logiki: osie + cuspy (whole sign) + pierścień znaków."""
|
||||||
|
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
|
||||||
|
glyphs = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]
|
||||||
|
asc_sign = int(asc // 30)
|
||||||
|
cusps = [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
|
||||||
|
"decimal": (asc_sign * 30 + i * 30) % 360, "sign_glyph": glyphs[(asc_sign + i) % 12]}
|
||||||
|
for i in range(12)]
|
||||||
|
return {
|
||||||
|
"angles": {
|
||||||
|
"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
|
||||||
|
"MC": {"name": "MC", "decimal": mc},
|
||||||
|
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
|
||||||
|
"IC": {"name": "IC", "decimal": (mc + 180) % 360},
|
||||||
|
},
|
||||||
|
"cusps": cusps,
|
||||||
|
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rendered():
|
||||||
|
return chartwheel.render(_sample_chart())
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_is_well_formed_xml():
|
||||||
|
"""Wstawiamy surowy SVG do strony — musi być poprawnym XML, bo inaczej
|
||||||
|
zepsuje cały dokument."""
|
||||||
|
svg = _rendered().replace("var(--line)", "#000").replace(
|
||||||
|
"var(--accent)", "#000").replace("var(--muted)", "#000")
|
||||||
|
doc = minidom.parseString(svg) # rzuci wyjątkiem, gdy błędny
|
||||||
|
assert doc.documentElement.tagName == "svg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_twelve_sign_glyphs():
|
||||||
|
assert _rendered().count('class="glyph"') == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_all_four_axis_labels():
|
||||||
|
svg = _rendered()
|
||||||
|
for label in (">AC<", ">DC<", ">MC<", ">IC<"):
|
||||||
|
assert label in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_house_numbers_one_to_twelve():
|
||||||
|
svg = _rendered()
|
||||||
|
for h in range(1, 13):
|
||||||
|
assert f">{h}<" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_ascendant_is_on_the_left():
|
||||||
|
"""Konwencja: Asc po lewej. Sprawdzamy oś AC–DC liczbowo — punkt Asc musi mieć
|
||||||
|
x wyraźnie mniejszy od środka (220), a Dsc większy."""
|
||||||
|
chart = _sample_chart(asc=128.9)
|
||||||
|
asc = chart["angles"]["Asc"]["decimal"]
|
||||||
|
ax, ay = chartwheel._pt(asc, asc, chartwheel.R_ZOD)
|
||||||
|
dx, dy = chartwheel._pt(asc + 180, asc, chartwheel.R_ZOD)
|
||||||
|
assert ax < chartwheel._CX - 100, "Asc powinien być po lewej"
|
||||||
|
assert dx > chartwheel._CX + 100, "Dsc powinien być po prawej"
|
||||||
|
assert abs(ay - chartwheel._CY) < 1e-6, "oś Asc–Dsc jest pozioma"
|
||||||
|
|
||||||
|
|
||||||
|
def test_longitude_increases_counterclockwise():
|
||||||
|
"""Asc+90° (II dom) ląduje na DOLE koła (y > środek, bo w SVG y rośnie w dół)."""
|
||||||
|
asc = 100.0
|
||||||
|
_, y = chartwheel._pt(asc + 90, asc, chartwheel.R_ZOD)
|
||||||
|
assert y > chartwheel._CY + 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_when_no_angles():
|
||||||
|
"""Silnik bez osi/domów (brak sidereal) → brak koła, nie wyjątek."""
|
||||||
|
assert chartwheel.render({"positions": []}) == ""
|
||||||
|
assert chartwheel.render({"angles": {}, "cusps": [], "sign_glyphs": []}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── rozsuwanie nakładających się obiektów ─────────
|
||||||
|
|
||||||
|
# Prawdziwe długości z horoskopu referencyjnego (1984-04-30, Kraków), w kolejności
|
||||||
|
# DEFAULT_OBJECTS. Merkury i Wenus dzieli 0,41°, Wenus i Księżyc 3,68° — bez
|
||||||
|
# rozsuwania glify rysują się jeden na drugim.
|
||||||
|
_REAL = [40.210, 31.449, 27.355, 27.767, 234.520, 282.962, 223.306,
|
||||||
|
252.811, 271.220, 210.477, 68.158, 248.158, 345.667]
|
||||||
|
|
||||||
|
|
||||||
|
def _min_gap(angles):
|
||||||
|
s = sorted(a % 360.0 for a in angles)
|
||||||
|
return min((s[(i + 1) % len(s)] - s[i]) % 360.0 for i in range(len(s)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_spread_separates_real_chart_cluster():
|
||||||
|
"""REGRESJA: przy liczeniu odstępu modulo 360 przesunięcie, które przerzucało
|
||||||
|
obiekt ZA sąsiada, dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że
|
||||||
|
jest luzem, i kończył z Wenus dokładnie na Księżycu (0,3 px od siebie)."""
|
||||||
|
out = chartwheel.spread(_REAL)
|
||||||
|
assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_spread_preserves_order():
|
||||||
|
out = chartwheel.spread(_REAL)
|
||||||
|
by_new = [i for i, _ in sorted(enumerate(out), key=lambda p: p[1])]
|
||||||
|
by_old = [i for i, _ in sorted(enumerate(_REAL), key=lambda p: p[1] % 360.0)]
|
||||||
|
assert by_new == by_old
|
||||||
|
|
||||||
|
|
||||||
|
def test_spread_leaves_roomy_objects_untouched():
|
||||||
|
"""Obiekty, które nikomu nie wchodzą w drogę, mają zostać na swoim miejscu —
|
||||||
|
wykres ma kłamać jak najmniej."""
|
||||||
|
out = chartwheel.spread(_REAL)
|
||||||
|
node = _REAL.index(68.158) # Węzeł Płn. — 28° od najbliższego
|
||||||
|
assert out[node] == pytest.approx(_REAL[node], abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spread_handles_wrap_around_zero():
|
||||||
|
"""Skupisko na styku 0/360° też musi się rozsunąć."""
|
||||||
|
out = chartwheel.spread([358.0, 359.0, 1.0, 2.0])
|
||||||
|
assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_spread_falls_back_to_even_layout_when_impossible():
|
||||||
|
"""Gdy obiektów jest tyle, że min. odstęp się nie zmieści — rozkładamy równo,
|
||||||
|
zamiast kręcić się w pętli."""
|
||||||
|
many = [10.0] * 60 # 60 × 8° = 480° > 360°
|
||||||
|
out = chartwheel.spread(many)
|
||||||
|
assert _min_gap(out) == pytest.approx(360.0 / 60, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spread_single_object_is_identity():
|
||||||
|
assert chartwheel.spread([42.0]) == [42.0]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────── obiekty na kole ─────────────────────────
|
||||||
|
|
||||||
|
def _chart_with_objects():
|
||||||
|
chart = _sample_chart()
|
||||||
|
chart["positions"] = [
|
||||||
|
{"name": "Sun", "decimal": 40.21, "glyph": "☉", "direction": "D"},
|
||||||
|
{"name": "Moon", "decimal": 31.45, "glyph": "☽", "direction": "D"},
|
||||||
|
{"name": "Venus", "decimal": 27.77, "glyph": "♀", "direction": "D"},
|
||||||
|
{"name": "Mercury", "decimal": 27.36, "glyph": "☿", "direction": "Rx"},
|
||||||
|
]
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_objects_are_drawn():
|
||||||
|
svg = chartwheel.render(_chart_with_objects())
|
||||||
|
for glyph in ("☉", "☽", "♀", "☿"):
|
||||||
|
assert glyph in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_degree_in_sign_is_labelled():
|
||||||
|
"""Stopień W ZNAKU (nie długość absolutna): Słońce 40,21° → 10 Byka."""
|
||||||
|
svg = chartwheel.render(_chart_with_objects())
|
||||||
|
assert ">10<" in svg # Słońce
|
||||||
|
assert ">1<" in svg # Księżyc (31,45 → 1 Byka)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrograde_is_marked():
|
||||||
|
svg = chartwheel.render(_chart_with_objects())
|
||||||
|
assert "℞" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_retrograde_marker_when_all_direct():
|
||||||
|
chart = _chart_with_objects()
|
||||||
|
for p in chart["positions"]:
|
||||||
|
p["direction"] = "D"
|
||||||
|
assert "℞" not in chartwheel.render(chart)
|
||||||
|
|
||||||
|
|
||||||
|
def test_objects_without_longitude_are_skipped():
|
||||||
|
"""Niekompletny obiekt nie może wywalić całego rysunku."""
|
||||||
|
chart = _chart_with_objects()
|
||||||
|
chart["positions"].append({"name": "Broken", "glyph": "?"})
|
||||||
|
svg = chartwheel.render(chart)
|
||||||
|
assert svg and "☉" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_when_cusps_lack_longitude():
|
||||||
|
"""Starsze wyniki bez `decimal` w cuspach — degradujemy do pustego, nie sypiemy."""
|
||||||
|
chart = _sample_chart()
|
||||||
|
for c in chart["cusps"]:
|
||||||
|
del c["decimal"]
|
||||||
|
assert chartwheel.render(chart) == ""
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Zakładka „Skompiluj" — zbiorczy raport (PRE-23).
|
||||||
|
|
||||||
|
Raport składa się z trzech kawałków liczonych w różnych miejscach: horoskopu
|
||||||
|
(serwer), interpretacji natalnej i predykcji okresowych (magazyn przeglądarki).
|
||||||
|
Pilnujemy, żeby zakładka umiała je zebrać, a przede wszystkim — żeby POWIEDZIAŁA,
|
||||||
|
czego brakuje. Niekompletny raport, o którym dowiadujesz się dopiero z PDF-a,
|
||||||
|
byłby najgorszym wynikiem.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
TPL = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
|
||||||
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
|
||||||
|
NATAL_JS = (APP / "static" / "natal.js").read_text(encoding="utf-8")
|
||||||
|
INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8")
|
||||||
|
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────────────── nawigacja i trasa ─────────────────────
|
||||||
|
|
||||||
|
def test_tab_is_in_the_menu():
|
||||||
|
assert 'href="/compile"' in BASE and "Skompiluj" in BASE
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_renders_empty_form():
|
||||||
|
r = _client().get("/compile")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Złóż podsumowanie" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes_exist():
|
||||||
|
assert '@app.get("/compile"' in MAIN and '@app.post("/compile"' in MAIN
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── kolejność w raporcie ────────────────────────
|
||||||
|
|
||||||
|
def test_report_order_matches_the_brief():
|
||||||
|
"""Prośba partnerów: najpierw imię i nazwisko, potem wprowadzone dane, potem
|
||||||
|
RYSUNEK kosmogramu, a dopiero po nim interpretacja natalna i predykcje."""
|
||||||
|
person = TPL.index('id="reportPerson"')
|
||||||
|
wheel = TPL.index("wheel_svg")
|
||||||
|
natal = TPL.index('id="reportNatal"')
|
||||||
|
preds = TPL.index('id="reportPredictions"')
|
||||||
|
assert person < wheel < natal < preds
|
||||||
|
|
||||||
|
|
||||||
|
def test_person_name_heads_the_report():
|
||||||
|
assert 'id="reportPerson"' in TPL
|
||||||
|
assert "form.person" in TPL
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────── zbieranie materiału z magazynów ────────────────
|
||||||
|
|
||||||
|
def test_reads_stores_through_their_api_not_raw_storage():
|
||||||
|
"""Format danych ma jednego właściciela — moduł, który je zapisuje.
|
||||||
|
Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
|
||||||
|
assert "window.astrololoNatal" in JS
|
||||||
|
assert "window.astrololoPredictions" in JS
|
||||||
|
# chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
|
||||||
|
assert "localStorage.getItem" not in JS
|
||||||
|
assert "localStorage.setItem" not in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_stores_are_loaded_on_the_tab():
|
||||||
|
for src in ("natal.js", "predictions.js", "compile.js"):
|
||||||
|
assert src in TPL, f"brak {src} na zakładce"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stores_load_before_the_assembler():
|
||||||
|
"""Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach."""
|
||||||
|
def tag(name):
|
||||||
|
return TPL.index('src="/static/' + name + '"')
|
||||||
|
|
||||||
|
assert tag("natal.js") < tag("compile.js")
|
||||||
|
assert tag("predictions.js") < tag("compile.js")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── mówi, czego brakuje ─────────────────────────
|
||||||
|
|
||||||
|
def test_shows_what_is_missing():
|
||||||
|
assert 'id="readiness"' in TPL
|
||||||
|
assert "rdy-missing" in JS
|
||||||
|
for hint in ("Interpretacje", "Kalendarz"):
|
||||||
|
assert hint in JS, f"brak podpowiedzi, gdzie uzupełnić: {hint}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_three_parts_are_checked():
|
||||||
|
for label in ("Policzony horoskop", "Interpretacja natalna", "Predykcje okresowe"):
|
||||||
|
assert label in JS
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────────── natalna zapamiętywana ─────────────────────
|
||||||
|
|
||||||
|
def test_natal_is_captured_on_interpret_tab():
|
||||||
|
assert "natal.js" in INTERPRET
|
||||||
|
assert 'id="natalNote"' in INTERPRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_natal_store_keeps_a_single_entry():
|
||||||
|
"""Natalna jest JEDNA — dotyczy momentu urodzenia, nie okresu. Ponowne
|
||||||
|
wygenerowanie ma podmienić, nie dokładać."""
|
||||||
|
assert "setItem" in NATAL_JS and "push" not in NATAL_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_natal_only_stores_natal_profile():
|
||||||
|
assert "'natal'" in NATAL_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_storage_is_reported():
|
||||||
|
assert "pełny" in NATAL_JS
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────── bezpieczeństwo ──────────────────────────
|
||||||
|
|
||||||
|
def test_ai_text_is_escaped_before_injection():
|
||||||
|
"""Treść od modelu wstawiamy do DOM — bez ucieczki byłby to wektor wstrzyknięcia."""
|
||||||
|
assert "function esc" in JS
|
||||||
|
assert "replace(/</g, '<')" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_nothing_on_other_tabs():
|
||||||
|
assert "if (!document.getElementById('readiness')) return;" in JS
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Wspólne dane formularza między zakładkami + pole imienia (PRE-21).
|
||||||
|
|
||||||
|
Znów test strukturalny na źródle: synchronizacja żyje w JS, a jej awaria jest
|
||||||
|
CICHA — formularz po prostu przestaje pamiętać dane i użytkownik wpisuje je od
|
||||||
|
nowa. Pilnujemy więc reguł, które łatwo zgubić przy kolejnych zmianach.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
TEMPLATES = APP / "templates"
|
||||||
|
SYNC_JS = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
|
||||||
|
GEO_JS = (APP / "static" / "geo.js").read_text(encoding="utf-8")
|
||||||
|
BASE_HTML = (TEMPLATES / "base.html").read_text(encoding="utf-8")
|
||||||
|
MAIN_PY = (APP / "main.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
WITH_FORM = ("chart.html", "interpret.html", "timeline.html")
|
||||||
|
|
||||||
|
# Pola, które mają wędrować między zakładkami. Gdy dojdzie nowe wspólne pole,
|
||||||
|
# ten wykaz ma się nie rozjechać z formsync.js.
|
||||||
|
SHARED = ("person", "date", "time", "tz_offset", "lat", "lon",
|
||||||
|
"house_system", "zodiac", "place")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────── pole imienia ────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", WITH_FORM)
|
||||||
|
def test_person_field_present_on_every_form(name):
|
||||||
|
"""Imię i nazwisko trafia do nagłówka raportu PDF (PRE-24) — musi dać się
|
||||||
|
je wpisać z każdej zakładki, bo dane są wspólne."""
|
||||||
|
html = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
assert 'name="person"' in html, f"{name}: brak pola imienia"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", WITH_FORM)
|
||||||
|
def test_person_value_survives_submit(name):
|
||||||
|
"""Po wysłaniu formularza pole ma pokazywać to, co wpisano — inaczej znikałoby
|
||||||
|
przy każdym przeliczeniu."""
|
||||||
|
html = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
assert "form.person" in html, f"{name}: wartość imienia nie wraca po POST"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handlers_accept_and_echo_person():
|
||||||
|
"""Każdy handler renderujący formularz przyjmuje `person` i wkłada go
|
||||||
|
z powrotem do `form` — inaczej imię znikałoby po przeliczeniu.
|
||||||
|
Cztery: Horoskop, Interpretacje, Kalendarz, Skompiluj (PRE-23)."""
|
||||||
|
assert MAIN_PY.count("person: str = Form") == 4
|
||||||
|
assert MAIN_PY.count('"person": person') == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────── synchronizacja między zakładkami ────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", SHARED)
|
||||||
|
def test_every_shared_field_is_synced(key):
|
||||||
|
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_script_loaded_before_map_script():
|
||||||
|
"""KOLEJNOŚĆ MA ZNACZENIE: formsync w <head> z `defer` wykonuje się przed
|
||||||
|
geo.js (skrypty defer idą w kolejności dokumentu), więc mapa startuje już
|
||||||
|
z odtworzonymi współrzędnymi zamiast skakać po chwili."""
|
||||||
|
assert "formsync.js" in BASE_HTML, "formsync.js nie jest ładowany globalnie"
|
||||||
|
assert "defer" in BASE_HTML.split("formsync.js")[0].rsplit("<script", 1)[-1] + "defer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_reacts_to_other_browser_tab():
|
||||||
|
"""Zmiana w drugiej otwartej karcie ma dociągnąć się tutaj (zdarzenie storage)."""
|
||||||
|
assert "'storage'" in SYNC_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_persists_on_submit():
|
||||||
|
assert "'submit'" in SYNC_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_storage_does_not_break_the_page():
|
||||||
|
"""Uszkodzony wpis w localStorage nie może zablokować formularza."""
|
||||||
|
assert "catch" in SYNC_JS and "JSON.parse" in SYNC_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_changes_are_reported_to_sync():
|
||||||
|
"""geo.js ustawia pola z KODU (`.value = ...`), co samo z siebie nie wywołuje
|
||||||
|
zdarzeń — bez jawnego `change` synchronizacja przegapiłaby wybór z mapy."""
|
||||||
|
assert "function setVal" in GEO_JS
|
||||||
|
assert "dispatchEvent(new Event('change'" in GEO_JS
|
||||||
|
# wszystkie miejsca ustawiające pola idą przez setVal
|
||||||
|
assert "latEl.value =" not in GEO_JS.replace("el.value = value", "")
|
||||||
|
assert "searchEl.value = d.name" not in GEO_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_does_not_wipe_fields_missing_on_a_tab():
|
||||||
|
"""Zakładka bez danego pola (np. Sygnifikatory) nie może wyczyścić wartości
|
||||||
|
zapamiętanej z innej zakładki."""
|
||||||
|
assert "if (!el) return;" in SYNC_JS
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Zapamiętane predykcje okresowe (PRE-22).
|
||||||
|
|
||||||
|
Magazyn żyje w JS (localStorage), więc znów pilnujemy go strukturalnie. Sedno:
|
||||||
|
predykcje mają PRZEŻYĆ przejście na inną zakładkę i trafić do raportu, a lista
|
||||||
|
nie może puchnąć od powtórzonych prób na tym samym okresie.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
JS = (APP / "static" / "predictions.js").read_text(encoding="utf-8")
|
||||||
|
PROGRESS = (APP / "static" / "progress.js").read_text(encoding="utf-8")
|
||||||
|
TIMELINE = (APP / "templates" / "timeline.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────── podpięcie ───────────────────────────────
|
||||||
|
|
||||||
|
def test_script_loaded_on_calendar_tab():
|
||||||
|
assert "predictions.js" in TIMELINE
|
||||||
|
|
||||||
|
|
||||||
|
def test_loaded_after_progress_script():
|
||||||
|
"""Nasłuchuje zdarzenia, które wysyła progress.js — musi być załadowany po nim,
|
||||||
|
inaczej przegapi pierwsze wywołanie."""
|
||||||
|
assert TIMELINE.index("progress.js") < TIMELINE.index("predictions.js")
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_has_container_for_the_list():
|
||||||
|
assert 'id="savedPredictions"' in TIMELINE
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_announces_finished_horoscope():
|
||||||
|
"""Okno postępu ogłasza gotowy horoskop zdarzeniem, zamiast samo go zapisywać
|
||||||
|
— to okno postępu, a nie magazyn."""
|
||||||
|
assert "astrololo:horoscope" in PROGRESS
|
||||||
|
assert "profile" in PROGRESS.split("astrololo:horoscope")[1][:200]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────── zachowanie ──────────────────────────────
|
||||||
|
|
||||||
|
def test_only_period_horoscopes_are_stored():
|
||||||
|
"""Interpretacja natalna ma NIE trafiać na listę predykcji okresowych."""
|
||||||
|
assert "'period'" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_period_replaces_instead_of_duplicating():
|
||||||
|
"""Ponowne policzenie tego samego zakresu podmienia wpis. Inaczej lista
|
||||||
|
puchłaby przy każdej próbie z innym modelem."""
|
||||||
|
assert "function upsert" in JS
|
||||||
|
assert "p.from_date === entry.from_date" in JS
|
||||||
|
assert "p.to_date === entry.to_date" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_carries_period_and_text():
|
||||||
|
for field in ("from_date", "to_date", "text", "saved_at"):
|
||||||
|
assert field in JS, f"wpis nie niesie pola {field}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_result_is_not_stored():
|
||||||
|
"""Bez tekstu albo bez dat nie ma czego zapamiętać — nie zapisujemy śmieci."""
|
||||||
|
assert "if (!text || !from || !to) return null;" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_can_be_pruned():
|
||||||
|
assert "function remove" in JS
|
||||||
|
assert "data-drop" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_storage_is_reported_not_swallowed():
|
||||||
|
"""Horoskopy bywają długie. Gdy magazyn się przepełni, użytkownik musi się
|
||||||
|
o tym dowiedzieć, zamiast po cichu stracić wynik."""
|
||||||
|
assert "pełny" in JS or "pelny" in JS
|
||||||
|
assert "return false;" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_storage_does_not_break_the_page():
|
||||||
|
assert "catch" in JS and "Array.isArray" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_is_exposed_for_the_compile_tab():
|
||||||
|
"""Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy."""
|
||||||
|
assert "window.astrololoPredictions" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_nothing_on_other_tabs():
|
||||||
|
assert "if (!document.getElementById('savedPredictions')) return;" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_does_not_duplicate():
|
||||||
|
"""Wariant bez strumienia zapisuje przy wczytaniu strony — klucz to okres,
|
||||||
|
więc odświeżenie nie mnoży wpisów."""
|
||||||
|
assert "id: from + '..' + to" in JS
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Łącze do usługi render i wariant kosmogramu do druku (PRE-24).
|
||||||
|
|
||||||
|
Przez to łącze leci CAŁY raport: dane urodzeniowe i opisy z baz. Musi być
|
||||||
|
szyfrowane tak samo jak pozostałe, WŁASNYM kluczem — i musi obowiązywać ten sam
|
||||||
|
niezmiennik co dla klienta logiki: żadnego wyjścia w dół bez tokenu i klucza.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
CLIENT = APP / "clients" / "render_client.py"
|
||||||
|
SRC = CLIENT.read_text(encoding="utf-8")
|
||||||
|
WHEEL = (APP / "chartwheel.py").read_text(encoding="utf-8")
|
||||||
|
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
||||||
|
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── szyfrowanie łącza ───────────────────────────
|
||||||
|
|
||||||
|
def test_uses_its_own_third_key():
|
||||||
|
"""Osobny klucz na parę rozmówców. Przejęcie klucza renderu nie może otwierać
|
||||||
|
łącza do logiki ani do danych."""
|
||||||
|
from app import link_crypto
|
||||||
|
|
||||||
|
assert link_crypto.ENV_PRESENTATION_RENDER == "LINK_KEY_PRESENTATION_RENDER"
|
||||||
|
assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_PRESENTATION_LOGIC
|
||||||
|
assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_LOGIC_DATA
|
||||||
|
assert "ENV_PRESENTATION_RENDER" in SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_goes_through_the_encrypted_link():
|
||||||
|
assert "link_crypto.call(" in SRC
|
||||||
|
assert "link=_link()" in SRC
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_outbound_call_carries_token_and_key():
|
||||||
|
"""Ten sam niezmiennik co przy kliencie logiki — tam brak nagłówka wyszedł
|
||||||
|
dopiero na produkcji (401)."""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
tree = ast.parse(SRC)
|
||||||
|
calls = [n for n in ast.walk(tree)
|
||||||
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||||||
|
and n.func.attr in ("call", "call_json")]
|
||||||
|
assert calls, "nie znaleziono wywołania przez link_crypto"
|
||||||
|
for c in calls:
|
||||||
|
kwargs = {k.arg for k in c.keywords}
|
||||||
|
assert "headers" in kwargs and "link" in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_the_only_unencrypted_path():
|
||||||
|
"""/health musi zostać otwarty dla sond k8s — ale nic poza nim."""
|
||||||
|
from app import link_crypto
|
||||||
|
|
||||||
|
assert "/health" in link_crypto.PUBLIC_PATHS
|
||||||
|
raw = [line for line in SRC.splitlines() if "client.get(" in line or "client.post(" in line]
|
||||||
|
assert all("/health" in line for line in raw), f"surowe wywołania poza /health: {raw}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_is_read_lazily():
|
||||||
|
"""Konfiguracja może się zmienić bez restartu procesu (podmiana sekretu)."""
|
||||||
|
assert "def _link()" in SRC and "key_from_env" in SRC
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
|
||||||
|
|
||||||
|
def test_pdf_route_uses_print_theme():
|
||||||
|
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów."""
|
||||||
|
assert 'chartwheel.render(chart, theme="print")' in MAIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_theme_has_no_css_variables():
|
||||||
|
"""`var(--…)` w SVG poza stroną nie rozwiąże się — wyszłyby czarne albo
|
||||||
|
żadne kreski."""
|
||||||
|
from app import chartwheel
|
||||||
|
|
||||||
|
chart = _sample_chart()
|
||||||
|
printed = chartwheel.render(chart, theme="print")
|
||||||
|
assert printed and "var(--" not in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_theme_still_uses_css_variables():
|
||||||
|
"""Na stronie koło ma się dostrajać do motywu aplikacji."""
|
||||||
|
from app import chartwheel
|
||||||
|
|
||||||
|
assert "var(--" in chartwheel.render(_sample_chart(), theme="screen")
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_theme_embeds_the_glyph_font():
|
||||||
|
"""Font glifów podaje CSS klasy `.glyph` — poza stroną trzeba go wpisać
|
||||||
|
W SAM rysunek, inaczej kosmogram w PDF wyszedłby BEZ symboli."""
|
||||||
|
from app import chartwheel
|
||||||
|
|
||||||
|
printed = chartwheel.render(_sample_chart(), theme="print")
|
||||||
|
assert 'font-family=' in printed
|
||||||
|
assert "DejaVu Sans" in printed # font obecny w obrazie usługi render
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_theme_leaves_font_to_css():
|
||||||
|
from app import chartwheel
|
||||||
|
|
||||||
|
assert "font-family=" not in chartwheel.render(_sample_chart(), theme="screen")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_theme_falls_back_to_screen():
|
||||||
|
from app import chartwheel
|
||||||
|
|
||||||
|
assert chartwheel.render(_sample_chart(), theme="cokolwiek") == \
|
||||||
|
chartwheel.render(_sample_chart(), theme="screen")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── składanie po stronie klienta ────────────────
|
||||||
|
|
||||||
|
def test_browser_sends_all_three_parts():
|
||||||
|
for field in ("person", "natal", "predictions"):
|
||||||
|
assert field in COMPILE_JS, f"raport wysyłany bez części: {field}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_button_exists():
|
||||||
|
tpl = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
|
||||||
|
assert 'id="pdfBtn"' in tpl
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_failure_does_not_block_the_report():
|
||||||
|
"""Brak rysunku ma dać raport bez kosmogramu, a nie brak raportu — tekst
|
||||||
|
kosztował wywołanie modelu, rysunek policzymy zawsze."""
|
||||||
|
assert "wheel_svg = \"\"" in MAIN or 'wheel_svg, = ""' in MAIN
|
||||||
|
assert "Brak rysunku nie może zablokować raportu" in MAIN
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────── pomocnicze ──────────────────────────
|
||||||
|
|
||||||
|
def _sample_chart():
|
||||||
|
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
|
||||||
|
glyphs = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]
|
||||||
|
asc = 128.9
|
||||||
|
asc_sign = int(asc // 30)
|
||||||
|
return {
|
||||||
|
"angles": {"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
|
||||||
|
"MC": {"name": "MC", "decimal": 5.0},
|
||||||
|
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
|
||||||
|
"IC": {"name": "IC", "decimal": 185.0}},
|
||||||
|
"cusps": [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
|
||||||
|
"decimal": (asc_sign * 30 + i * 30) % 360} for i in range(12)],
|
||||||
|
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
|
||||||
|
"positions": [{"name": "Sun", "decimal": 40.2, "glyph": "☉", "direction": "D"}],
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Poprawki zgłoszone przez partnerów: offset względem GMT i lokalizacja po
|
||||||
|
kliknięciu „Tu i teraz" (PRE-19, PRE-20).
|
||||||
|
|
||||||
|
JS-a tu nie uruchomimy, więc pilnujemy tego strukturalnie — tak samo jak
|
||||||
|
niezmienników klienta HTTP. Obie regresje są ciche (nic się nie wywala, tylko
|
||||||
|
formularz pokazuje nieprawdę), więc test na źródle jest tu wart więcej niż zwykle.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
TEMPLATES = APP / "templates"
|
||||||
|
GEO_JS = APP / "static" / "geo.js"
|
||||||
|
|
||||||
|
WITH_OFFSET = ("chart.html", "interpret.html", "timeline.html")
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────────── PRE-19: offset względem GMT ───────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", WITH_OFFSET)
|
||||||
|
def test_offset_label_says_relative_to_gmt(name):
|
||||||
|
"""Etykieta musi mówić, WZGLĘDEM CZEGO liczymy przesunięcie."""
|
||||||
|
html = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
assert "Offset względem GMT" in html, f"{name}: etykieta nie mówi o GMT"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", WITH_OFFSET)
|
||||||
|
def test_offset_step_is_quarter_hour(name):
|
||||||
|
"""Krok 15 minut = 0,25 h. Strefy pół- i ćwierćgodzinne (Indie +5:30,
|
||||||
|
Nepal +5:45) są realne — przy kroku 1 h nie dało się ich ustawić."""
|
||||||
|
html = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
assert 'name="tz_offset" step="0.25"' in html, f"{name}: krok inny niż 15 minut"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", WITH_OFFSET)
|
||||||
|
def test_offset_range_covers_real_zones(name):
|
||||||
|
html = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
assert 'min="-12"' in html and 'max="14"' in html, f"{name}: brak sensownego zakresu stref"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────── PRE-20: „Tu i teraz" odświeża nazwę miejsca ────────
|
||||||
|
|
||||||
|
def _geo_source() -> str:
|
||||||
|
return GEO_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _handler_body() -> str:
|
||||||
|
"""Ciało handlera zdarzenia astrololo:coords (wysyłanego przez „Tu i teraz").
|
||||||
|
|
||||||
|
Szukamy KONKRETNIE `addEventListener`, nie samej nazwy zdarzenia — ta pada
|
||||||
|
także w komentarzu nagłówkowym pliku i wtedy wycięlibyśmy komentarz zamiast
|
||||||
|
kodu (test przechodziłby albo padał z niewłaściwego powodu).
|
||||||
|
"""
|
||||||
|
src = _geo_source()
|
||||||
|
start = src.index("addEventListener('astrololo:coords'")
|
||||||
|
end = src.index("});", start)
|
||||||
|
return src[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_button_handler_refreshes_place_name():
|
||||||
|
"""REGRESJA: po „Tu i teraz" pineska i współrzędne skakały na nowe miejsce,
|
||||||
|
a w polu tekstowym zostawała stara nazwa — formularz pokazywał jedno miejsce,
|
||||||
|
a liczył dla innego."""
|
||||||
|
assert "reverseName(" in _handler_body(), (
|
||||||
|
"handler astrololo:coords nie odświeża nazwy miejsca (reverseName)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_name_clears_field_before_lookup():
|
||||||
|
"""Nazwa jest czyszczona ZANIM przyjdzie odpowiedź. Gdyby /reverse nie
|
||||||
|
odpowiedziało (brak sieci), zostałaby stara nazwa — czyli pole kłamałoby
|
||||||
|
o lokalizacji. Lepiej puste pole niż nieprawdziwe."""
|
||||||
|
src = _geo_source()
|
||||||
|
start = src.index("function reverseName")
|
||||||
|
body = src[start:src.index("\n }", start)]
|
||||||
|
# sposób zapisu może się zmienić (bezpośrednie .value albo setVal, które
|
||||||
|
# dodatkowo zgłasza `change`) — pilnujemy KOLEJNOŚCI, nie składni
|
||||||
|
clear_at = max(body.find("searchEl.value = ''"), body.find("setVal(searchEl, '')"))
|
||||||
|
fetch_at = body.find("fetch(")
|
||||||
|
assert clear_at != -1, "reverseName nie czyści pola nazwy"
|
||||||
|
assert clear_at < fetch_at, "pole czyszczone dopiero po zapytaniu — stara nazwa zdąży zostać"
|
||||||
|
|
||||||
|
|
||||||
|
def test_now_button_still_syncs_map():
|
||||||
|
"""Odświeżenie nazwy nie może zastąpić przesunięcia pineski i widoku mapy."""
|
||||||
|
body = _handler_body()
|
||||||
|
assert "marker.setLatLng" in body and "map.setView" in body
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Powiększanie kosmogramu na pełne okno (PRE-25).
|
||||||
|
|
||||||
|
Test strukturalny, jak przy pozostałych rzeczach żyjących w JS/CSS. Osobno
|
||||||
|
pilnujemy warstwy nakładki: to jedyna liczba w tym pliku, którą łatwo zmienić
|
||||||
|
bez zastanowienia i zepsuć coś, czego się nie widzi na pierwszy rzut oka.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
|
||||||
|
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
||||||
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_is_loaded_globally():
|
||||||
|
"""Ładowane w base.html, bo koło pojawi się też na zakładce Skompiluj."""
|
||||||
|
assert "wheelzoom.js" in BASE
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggles_on_click():
|
||||||
|
assert "addEventListener('click'" in JS
|
||||||
|
assert "classList.toggle" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_closes_with_escape():
|
||||||
|
assert "'Escape'" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_reachable_from_keyboard():
|
||||||
|
"""Bez tego jedyny sposób na obejrzenie szczegółów wykresu wymagałby myszy."""
|
||||||
|
assert "tabindex" in JS
|
||||||
|
assert "'Enter'" in JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_scroll_is_locked_while_zoomed():
|
||||||
|
"""Inaczej przewijanie „przecieka" na treść pod spodem, której i tak nie widać."""
|
||||||
|
assert "wheel-zoom-open" in JS
|
||||||
|
assert re.search(r"body\.wheel-zoom-open\s*\{[^}]*overflow:\s*hidden", CSS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoomed_layer_sits_above_map_but_below_progress():
|
||||||
|
"""Warstwy: kontrolki Leafleta 1000 < kosmogram < okno postępu 1200.
|
||||||
|
Gdy trwa pisanie horoskopu, log operacji ma zostać na wierzchu."""
|
||||||
|
zoom = re.search(r"\.wheel-fig\.zoomed\s*\{[^}]*z-index:\s*(\d+)", CSS)
|
||||||
|
progress = re.search(r"\.progress-overlay\s*\{[^}]*z-index:\s*(\d+)", CSS)
|
||||||
|
assert zoom and progress, "nie znaleziono warstw w CSS"
|
||||||
|
assert 1000 < int(zoom.group(1)) < int(progress.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoomed_wheel_is_bounded_by_shorter_window_side():
|
||||||
|
"""Koło jest kwadratowe — ograniczamy je krótszym bokiem okna, inaczej na
|
||||||
|
szerokim ekranie wystawałoby w pionie."""
|
||||||
|
block = re.search(r"\.wheel-fig\.zoomed svg\.wheel\s*\{[^}]*\}", CSS)
|
||||||
|
assert block and "vh" in block.group(0) and "vw" in block.group(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_signals_the_affordance():
|
||||||
|
assert "zoom-in" in CSS and "zoom-out" in CSS
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_nothing_without_a_wheel():
|
||||||
|
"""Na zakładkach bez kosmogramu skrypt ma po prostu wyjść."""
|
||||||
|
assert "if (!figures.length) return;" in JS
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Usługa render: składa raport PDF (PRE-24).
|
||||||
|
#
|
||||||
|
# Obraz jest CIĘŻKI (TeX Live) i właśnie dlatego stoi osobno — patrz app/main.py.
|
||||||
|
# Dobieramy pakiety wąsko, zamiast brać texlive-full (kilka GB):
|
||||||
|
# texlive-xetex — silnik XeLaTeX (unicode + fonty systemowe),
|
||||||
|
# texlive-latex-recommended — geometry, graphicx,
|
||||||
|
# texlive-fontspec... (w xetex) — wybór fontu systemowego,
|
||||||
|
# fonts-dejavu-core — polskie znaki ORAZ glify astrologiczne (♄ ♓ ☉),
|
||||||
|
# librsvg2-bin — rsvg-convert: SVG kosmogramu → PDF.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
texlive-xetex \
|
||||||
|
texlive-latex-recommended \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
librsvg2-bin \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Sprawdzenie przy budowie: brak narzędzia ma wyjść TERAZ, nie przy pierwszym
|
||||||
|
# raporcie na produkcji.
|
||||||
|
RUN xelatex --version > /dev/null && rsvg-convert --version > /dev/null \
|
||||||
|
&& echo "xelatex + rsvg-convert OK"
|
||||||
|
|
||||||
|
EXPOSE 8004
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8004"]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Kompilacja raportu do PDF (PRE-24).
|
||||||
|
|
||||||
|
Dwa kroki zewnętrzne, oba w katalogu tymczasowym, który po sobie sprzątamy:
|
||||||
|
1. SVG kosmogramu → PDF (`rsvg-convert`). LaTeX nie wstawia SVG wprost;
|
||||||
|
zamiana na PDF zachowuje wektor, więc rysunek zostaje ostry przy dowolnym
|
||||||
|
powiększeniu.
|
||||||
|
2. .tex → PDF (`xelatex`).
|
||||||
|
|
||||||
|
Świadomie NIE logujemy treści raportu — to dane urodzeniowe i opisy z baz.
|
||||||
|
Do logu idzie wyłącznie ogon komunikatów LaTeXa przy błędzie, bo bez niego
|
||||||
|
diagnoza jest niemożliwa.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from app.latex import build
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.render")
|
||||||
|
|
||||||
|
WHEEL_SVG = "wheel.svg"
|
||||||
|
WHEEL_PDF = "wheel.pdf"
|
||||||
|
DOC = "report"
|
||||||
|
TIMEOUT = 120 # xelatex na dużym tekście potrzebuje chwili
|
||||||
|
|
||||||
|
|
||||||
|
class RenderError(Exception):
|
||||||
|
"""Kompilacja się nie udała — z komunikatem nadającym się do pokazania."""
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], cwd: pathlib.Path) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_to_pdf(svg: str, work: pathlib.Path) -> str | None:
|
||||||
|
"""Zwraca nazwę pliku PDF z kosmogramem albo None, gdy nie ma czego rysować."""
|
||||||
|
if not svg or not svg.strip():
|
||||||
|
return None
|
||||||
|
(work / WHEEL_SVG).write_text(svg, encoding="utf-8")
|
||||||
|
proc = _run(["rsvg-convert", "-f", "pdf", "-o", WHEEL_PDF, WHEEL_SVG], work)
|
||||||
|
if proc.returncode != 0 or not (work / WHEEL_PDF).exists():
|
||||||
|
# Brak rysunku nie może wywalić całego raportu — tekst jest ważniejszy.
|
||||||
|
log.warning("konwersja kosmogramu nie udała się: %s", (proc.stderr or "")[-300:])
|
||||||
|
return None
|
||||||
|
return WHEEL_PDF
|
||||||
|
|
||||||
|
|
||||||
|
def to_pdf(report: dict) -> bytes:
|
||||||
|
"""Raport (dict) → bajty PDF. Rzuca RenderError, gdy LaTeX nie zbuduje pliku."""
|
||||||
|
with tempfile.TemporaryDirectory(prefix="astrololo-render-") as tmp:
|
||||||
|
work = pathlib.Path(tmp)
|
||||||
|
wheel = _svg_to_pdf(report.get("wheel_svg") or "", work)
|
||||||
|
(work / f"{DOC}.tex").write_text(build(report, wheel), encoding="utf-8")
|
||||||
|
|
||||||
|
proc = _run(
|
||||||
|
["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"{DOC}.tex"],
|
||||||
|
work,
|
||||||
|
)
|
||||||
|
pdf = work / f"{DOC}.pdf"
|
||||||
|
if not pdf.exists():
|
||||||
|
tail = (proc.stdout or "")[-1200:]
|
||||||
|
log.error("xelatex nie zbudował PDF-a; ogon logu: %s", tail)
|
||||||
|
raise RenderError("LaTeX nie zbudował dokumentu. Ogon logu: " + tail[-400:])
|
||||||
|
return pdf.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def toolchain_ready() -> dict:
|
||||||
|
"""Czy narzędzia są na miejscu — do /health, żeby awarię obrazu widzieć od razu,
|
||||||
|
a nie przy pierwszym raporcie."""
|
||||||
|
return {
|
||||||
|
"xelatex": bool(shutil.which("xelatex")),
|
||||||
|
"rsvg-convert": bool(shutil.which("rsvg-convert")),
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Budowa dokumentu LaTeX raportu (PRE-24).
|
||||||
|
|
||||||
|
Układ wg prośby partnerów:
|
||||||
|
1. imię i nazwisko,
|
||||||
|
2. wprowadzone dane,
|
||||||
|
3. rysunek kosmogramu,
|
||||||
|
4. interpretacja natalna,
|
||||||
|
5. predykcje okresowe.
|
||||||
|
|
||||||
|
Silnik: XeLaTeX. Nie pdflatex — tekst zawiera polskie znaki ORAZ glify
|
||||||
|
astrologiczne (♄ ♓ ☉ …), a te wymagają silnika unicode'owego z fontem systemowym.
|
||||||
|
DejaVu Sans ma jedno i drugie, dlatego jest fontem głównym.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Znaki, które w LaTeXu mają własne znaczenie.
|
||||||
|
#
|
||||||
|
# Odwrotnego ukośnika NIE MA na tej liście celowo. Jego zamiennik
|
||||||
|
# (`\textbackslash{}`) sam zawiera nawiasy klamrowe, więc gdyby szedł w tej samej
|
||||||
|
# pętli, kolejne podmiany uciekłyby nawiasy, które dopiero co wstawiliśmy —
|
||||||
|
# i zamiast ukośnika wyszedłby dosłowny tekst `\textbackslash\{\}`. Dlatego
|
||||||
|
# ukośnik chowamy najpierw pod znacznik zastępczy, a rozwijamy na samym końcu.
|
||||||
|
_ESCAPES = [
|
||||||
|
("{", r"\{"),
|
||||||
|
("}", r"\}"),
|
||||||
|
("$", r"\$"),
|
||||||
|
("&", r"\&"),
|
||||||
|
("#", r"\#"),
|
||||||
|
("_", r"\_"),
|
||||||
|
("%", r"\%"),
|
||||||
|
("~", r"\textasciitilde{}"),
|
||||||
|
("^", r"\textasciicircum{}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Znak spoza tekstu użytkownika — nie da się go wpisać, więc nie da się nim
|
||||||
|
# oszukać ucieczki.
|
||||||
|
_BACKSLASH_MARK = "\x00astrololo-backslash\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def esc(text: object) -> str:
|
||||||
|
"""Ucieka znaki specjalne LaTeXa.
|
||||||
|
|
||||||
|
Najgroźniejszy jest `%`: bez ucieczki komentuje RESZTĘ LINII, więc tekst od
|
||||||
|
modelu urwałby się w środku zdania i nikt by tego nie zauważył — PDF
|
||||||
|
powstałby normalnie, tylko krótszy. Dlatego to nie kosmetyka, a warunek
|
||||||
|
poprawności raportu.
|
||||||
|
"""
|
||||||
|
out = "" if text is None else str(text)
|
||||||
|
out = out.replace("\\", _BACKSLASH_MARK) # schowaj, żeby nie ucierpiał niżej
|
||||||
|
for ch, rep in _ESCAPES:
|
||||||
|
out = out.replace(ch, rep)
|
||||||
|
return out.replace(_BACKSLASH_MARK, r"\textbackslash{}")
|
||||||
|
|
||||||
|
|
||||||
|
def _paragraphs(text: str) -> str:
|
||||||
|
"""Tekst od modelu → akapity LaTeXa.
|
||||||
|
|
||||||
|
Model oddziela akapity pustą linią. Zamieniamy je na osobne akapity, a same
|
||||||
|
złamania wiersza w środku akapitu zostawiamy LaTeXowi do złożenia.
|
||||||
|
"""
|
||||||
|
blocks = [b.strip() for b in str(text or "").split("\n\n")]
|
||||||
|
return "\n\n".join(esc(b).replace("\n", " ") for b in blocks if b)
|
||||||
|
|
||||||
|
|
||||||
|
PREAMBLE = r"""\documentclass[11pt,a4paper]{article}
|
||||||
|
\usepackage{fontspec}
|
||||||
|
\usepackage[margin=2.4cm]{geometry}
|
||||||
|
\usepackage{graphicx}
|
||||||
|
% DejaVu Sans ma i polskie znaki, i glify astrologiczne — jeden font na oba
|
||||||
|
% zadania oszczędza nam dobierania fontu zapasowego.
|
||||||
|
\setmainfont{DejaVu Sans}
|
||||||
|
\setlength{\parindent}{0pt}
|
||||||
|
\setlength{\parskip}{0.6em}
|
||||||
|
\pagestyle{plain}
|
||||||
|
\begin{document}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build(report: dict, wheel_pdf: str | None = None) -> str:
|
||||||
|
"""Składa źródło .tex. `wheel_pdf` to nazwa pliku z kosmogramem (już PDF)."""
|
||||||
|
person = str(report.get("person") or "").strip()
|
||||||
|
data = report.get("data") or {}
|
||||||
|
natal = report.get("natal") or {}
|
||||||
|
predictions = report.get("predictions") or []
|
||||||
|
|
||||||
|
parts = [PREAMBLE]
|
||||||
|
|
||||||
|
# ── 1. imię i nazwisko ──────────────────────────────────────────────
|
||||||
|
parts.append(r"{\Huge\bfseries " + esc(person or "Raport astrologiczny") + r"\par}")
|
||||||
|
parts.append(r"\vspace{0.3em}")
|
||||||
|
|
||||||
|
# ── 2. wprowadzone dane ─────────────────────────────────────────────
|
||||||
|
rows = [
|
||||||
|
("Data urodzenia", data.get("date")),
|
||||||
|
("Godzina (lokalna)", data.get("time")),
|
||||||
|
("Offset względem GMT", data.get("tz_offset")),
|
||||||
|
("Szerokość", data.get("lat")),
|
||||||
|
("Długość", data.get("lon")),
|
||||||
|
("Miejsce", data.get("place")),
|
||||||
|
("System domów", data.get("house_system")),
|
||||||
|
("Zodiak", data.get("zodiac")),
|
||||||
|
]
|
||||||
|
parts.append(r"\begin{tabular}{@{}ll@{}}")
|
||||||
|
for label, value in rows:
|
||||||
|
if value not in (None, ""):
|
||||||
|
parts.append(esc(label) + r" & " + esc(value) + r"\\")
|
||||||
|
parts.append(r"\end{tabular}")
|
||||||
|
parts.append(r"\vspace{1em}")
|
||||||
|
|
||||||
|
# ── 3. kosmogram ────────────────────────────────────────────────────
|
||||||
|
if wheel_pdf:
|
||||||
|
parts.append(r"\begin{center}")
|
||||||
|
parts.append(r"\includegraphics[width=0.82\textwidth]{" + wheel_pdf + r"}")
|
||||||
|
parts.append(r"\end{center}")
|
||||||
|
parts.append(r"\vspace{0.5em}")
|
||||||
|
|
||||||
|
# ── 4. interpretacja natalna ────────────────────────────────────────
|
||||||
|
if natal.get("text"):
|
||||||
|
parts.append(r"\section*{Interpretacja natalna}")
|
||||||
|
parts.append(_paragraphs(natal["text"]))
|
||||||
|
|
||||||
|
# ── 5. predykcje okresowe ───────────────────────────────────────────
|
||||||
|
if predictions:
|
||||||
|
parts.append(r"\section*{Predykcje okresowe}")
|
||||||
|
for p in predictions:
|
||||||
|
span = f"{p.get('from_date', '')} – {p.get('to_date', '')}"
|
||||||
|
parts.append(r"\subsection*{" + esc(span) + r"}")
|
||||||
|
parts.append(_paragraphs(p.get("text")))
|
||||||
|
|
||||||
|
parts.append(r"\end{document}")
|
||||||
|
return "\n".join(parts) + "\n"
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||||
|
|
||||||
|
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||||
|
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||||
|
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||||
|
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||||
|
zrzut z węzła), miał je w całości.
|
||||||
|
|
||||||
|
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||||
|
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||||
|
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||||
|
podatne na modyfikację".
|
||||||
|
|
||||||
|
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||||
|
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||||
|
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||||
|
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||||
|
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||||
|
|
||||||
|
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||||
|
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||||
|
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||||
|
|
||||||
|
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||||
|
|
||||||
|
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||||
|
|
||||||
|
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||||
|
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||||
|
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||||
|
|
||||||
|
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||||
|
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||||
|
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||||
|
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.link")
|
||||||
|
|
||||||
|
MAGIC = b"AL1"
|
||||||
|
VERSION = "v1"
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
KEY_BYTES = 32 # AES-256
|
||||||
|
LENGTH_PREFIX = 4
|
||||||
|
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||||
|
MAX_SKEW_SECONDS = 300.0
|
||||||
|
|
||||||
|
HEADER_ENC = "X-Astrololo-Enc"
|
||||||
|
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||||
|
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||||
|
|
||||||
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||||
|
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||||
|
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||||
|
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||||
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|
||||||
|
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||||
|
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(Exception):
|
||||||
|
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def parse_key(raw: str) -> bytes:
|
||||||
|
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise LinkError("pusty klucz łącza")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(text, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
raise LinkError(
|
||||||
|
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||||
|
f"`openssl rand -hex 32`"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_env(env_name: str) -> bytes | None:
|
||||||
|
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||||
|
raw = os.getenv(env_name, "")
|
||||||
|
return parse_key(raw) if raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_required() -> bool:
|
||||||
|
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||||
|
|
||||||
|
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||||
|
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||||
|
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||||
|
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||||
|
"""
|
||||||
|
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||||
|
info=b"astrololo/link/" + direction,
|
||||||
|
).derive(link_key)
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||||
|
|
||||||
|
def __init__(self, link_key: bytes) -> None:
|
||||||
|
self._by_direction = {
|
||||||
|
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||||
|
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- pojedyncza ramka
|
||||||
|
|
||||||
|
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||||
|
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||||
|
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||||
|
|
||||||
|
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
plaintext: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
sealed = self._by_direction[direction].encrypt(
|
||||||
|
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||||
|
return MAGIC + nonce + sealed
|
||||||
|
|
||||||
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
frame: bytes) -> bytes:
|
||||||
|
if not frame.startswith(MAGIC):
|
||||||
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
|
body = frame[len(MAGIC):]
|
||||||
|
if len(body) <= NONCE_BYTES:
|
||||||
|
raise LinkError("ramka za krótka")
|
||||||
|
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
||||||
|
try:
|
||||||
|
return self._by_direction[direction].decrypt(
|
||||||
|
nonce, sealed, self._aad(direction, path, stamp, seq))
|
||||||
|
except InvalidTag as exc:
|
||||||
|
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
||||||
|
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
||||||
|
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
||||||
|
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ strumień ramek
|
||||||
|
|
||||||
|
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
||||||
|
for seq, chunk in enumerate(chunks):
|
||||||
|
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
||||||
|
|
||||||
|
def open_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
raw: bytes) -> Iterator[bytes]:
|
||||||
|
for seq, frame in enumerate(frames_in(raw)):
|
||||||
|
yield self.open(direction, path, stamp, seq, frame)
|
||||||
|
|
||||||
|
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
||||||
|
return b"".join(self.open_stream(direction, path, stamp, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ramkowanie
|
||||||
|
|
||||||
|
def frame_out(payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frames_in(raw: bytes) -> Iterator[bytes]:
|
||||||
|
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
||||||
|
offset = 0
|
||||||
|
while offset < len(raw):
|
||||||
|
if offset + LENGTH_PREFIX > len(raw):
|
||||||
|
raise LinkError("urwana ramka (brak nagłówka długości)")
|
||||||
|
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
offset += LENGTH_PREFIX
|
||||||
|
if offset + size > len(raw):
|
||||||
|
raise LinkError("urwana ramka (za mało danych)")
|
||||||
|
yield raw[offset:offset + size]
|
||||||
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
|
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
||||||
|
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
||||||
|
|
||||||
|
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
||||||
|
rozjechać się między dwa odczyty.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if len(buffer) < LENGTH_PREFIX:
|
||||||
|
return
|
||||||
|
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
if len(buffer) < LENGTH_PREFIX + size:
|
||||||
|
return
|
||||||
|
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
||||||
|
del buffer[:LENGTH_PREFIX + size]
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- świeżość ruchu
|
||||||
|
|
||||||
|
def stamp_now() -> str:
|
||||||
|
return f"{time.time():.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_stamp(stamp: str) -> None:
|
||||||
|
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
||||||
|
odtworzyć w dowolnym momencie w przyszłości."""
|
||||||
|
try:
|
||||||
|
sent = float(stamp)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise LinkError("brak albo błędny znacznik czasu") from exc
|
||||||
|
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
||||||
|
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================== strona serwerowa
|
||||||
|
|
||||||
|
class LinkCryptoMiddleware:
|
||||||
|
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
||||||
|
|
||||||
|
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
||||||
|
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
||||||
|
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, link: Link | None, layer: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.link = link
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
path = scope["path"]
|
||||||
|
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||||
|
|
||||||
|
if headers.get(HEADER_ENC.lower()) != VERSION:
|
||||||
|
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
||||||
|
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
||||||
|
# chcemy po cichu wrócić do jawnego ruchu.
|
||||||
|
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
||||||
|
self.layer, path)
|
||||||
|
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
||||||
|
|
||||||
|
stamp = headers.get(HEADER_TS.lower(), "")
|
||||||
|
try:
|
||||||
|
check_stamp(stamp)
|
||||||
|
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
||||||
|
except LinkError as exc:
|
||||||
|
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
||||||
|
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
||||||
|
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
||||||
|
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
||||||
|
|
||||||
|
def _sealing_send(self, send, path: str):
|
||||||
|
state: dict = {"stamp": "", "seq": 0}
|
||||||
|
|
||||||
|
async def sealing(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
state["stamp"] = stamp_now()
|
||||||
|
keep = [(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
message = dict(message)
|
||||||
|
message["headers"] = keep + [
|
||||||
|
(b"content-type", CONTENT_TYPE.encode()),
|
||||||
|
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
||||||
|
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
||||||
|
]
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
chunk = message.get("body", b"")
|
||||||
|
sealed = b""
|
||||||
|
if chunk:
|
||||||
|
sealed = frame_out(self.link.seal(
|
||||||
|
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
||||||
|
state["seq"] += 1
|
||||||
|
return await send({"type": "http.response.body", "body": sealed,
|
||||||
|
"more_body": message.get("more_body", False)})
|
||||||
|
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
return sealing
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
||||||
|
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
||||||
|
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
||||||
|
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
kept.append((b"content-length", str(length).encode()))
|
||||||
|
if length:
|
||||||
|
kept.append((b"content-type", b"application/json"))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay(body: bytes, original):
|
||||||
|
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
||||||
|
|
||||||
|
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
||||||
|
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
||||||
|
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
||||||
|
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
||||||
|
"""
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return await original()
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(send, detail: str) -> None:
|
||||||
|
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
||||||
|
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
||||||
|
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
||||||
|
await send({"type": "http.response.start", "status": 400, "headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(payload)).encode()),
|
||||||
|
]})
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, env_name: str, layer: str):
|
||||||
|
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
||||||
|
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
||||||
|
link_key = key_from_env(env_name)
|
||||||
|
if link_key is None and encryption_required():
|
||||||
|
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
||||||
|
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
||||||
|
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
||||||
|
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
||||||
|
)
|
||||||
|
if link_key is None:
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
||||||
|
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
||||||
|
"ruch wewnątrz sieci.", env_name, layer,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
link = Link(link_key)
|
||||||
|
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
||||||
|
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ strona kliencka
|
||||||
|
|
||||||
|
def call(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
||||||
|
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
||||||
|
|
||||||
|
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
||||||
|
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
||||||
|
się niejasnym błędem odszyfrowania.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
||||||
|
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
||||||
|
# sygnifikatory, o które pytamy bazę.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
||||||
|
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
response = client.request(method, url, json=payload, headers=request_headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
||||||
|
"Content-Type": CONTENT_TYPE})
|
||||||
|
|
||||||
|
response = client.request(method, url, content=body, headers=request_headers)
|
||||||
|
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
||||||
|
reply_stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(reply_stamp)
|
||||||
|
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return _json.loads(call(client, method, url, payload=payload,
|
||||||
|
headers=headers, link=link))
|
||||||
|
|
||||||
|
|
||||||
|
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||||
|
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
||||||
|
|
||||||
|
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
||||||
|
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
||||||
|
"""
|
||||||
|
if link is None:
|
||||||
|
yield from response.iter_bytes()
|
||||||
|
return
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
||||||
|
stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(stamp)
|
||||||
|
path = response.request.url.path
|
||||||
|
buffer = bytearray()
|
||||||
|
seq = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
buffer += chunk
|
||||||
|
for frame in unframe_incremental(buffer):
|
||||||
|
yield link.open(RESPONSE, path, stamp, seq, frame)
|
||||||
|
seq += 1
|
||||||
|
if buffer:
|
||||||
|
raise LinkError("strumień urwał się w połowie ramki")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||||
|
link: Link | None) -> Iterator[str]:
|
||||||
|
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||||
|
|
||||||
|
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||||
|
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||||
|
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||||
|
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||||
|
dopiero na znakach nowej linii.
|
||||||
|
|
||||||
|
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||||
|
działał bez zmian.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||||
|
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||||
|
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||||
|
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for text_line in response.iter_lines():
|
||||||
|
if text_line:
|
||||||
|
yield text_line
|
||||||
|
return
|
||||||
|
|
||||||
|
path = _httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||||
|
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
buffer = bytearray()
|
||||||
|
for plain in open_response_stream(response, link):
|
||||||
|
buffer += plain
|
||||||
|
while True:
|
||||||
|
nl = buffer.find(b"\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
text_line = bytes(buffer[:nl])
|
||||||
|
del buffer[:nl + 1]
|
||||||
|
if text_line:
|
||||||
|
yield text_line.decode("utf-8")
|
||||||
|
if buffer:
|
||||||
|
yield bytes(buffer).decode("utf-8")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Warstwa RENDER — usługa składająca raport PDF (PRE-24).
|
||||||
|
|
||||||
|
OSOBNA usługa, nie część prezentacji, z tego samego powodu co silnik swisseph
|
||||||
|
(LOG-27): TeX Live waży setki megabajtów. Trzymanie go w obrazie produktu
|
||||||
|
spowalniałoby każdy build i deploy, a tak aktualizuje się niezależnie i awaria
|
||||||
|
renderu nie kładzie aplikacji.
|
||||||
|
|
||||||
|
Rozmawia TYLKO z prezentacją, po szyfrowanym łączu (własny, trzeci klucz —
|
||||||
|
dostaje cały raport: dane urodzeniowe i opisy z baz). W klastrze wystawiona jako
|
||||||
|
ClusterIP, bez wejścia z zewnątrz.
|
||||||
|
|
||||||
|
Świadomie nie zapisuje niczego na dysk poza katalogiem tymczasowym, który sprząta
|
||||||
|
po sobie — raport nie może zostawiać śladu w kontenerze.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from app import link_crypto, security
|
||||||
|
from app.compile import RenderError, to_pdf, toolchain_ready
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.render")
|
||||||
|
|
||||||
|
app = FastAPI(title="astrololo · usługa render (PDF)")
|
||||||
|
security.install(app, "render") # token międzywarstwowy (LOG-32)
|
||||||
|
# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa
|
||||||
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
link_crypto.install(app, link_crypto.ENV_PRESENTATION_RENDER, "render")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/pdf")
|
||||||
|
def build_pdf(report: dict) -> Response:
|
||||||
|
"""Raport (JSON) → PDF. Wejście: person, data, wheel_svg, natal, predictions.
|
||||||
|
|
||||||
|
Zwracamy surowe bajty PDF-a, nie base64 — łącze i tak jest szyfrowane, więc
|
||||||
|
kodowanie tylko rozdmuchałoby odpowiedź o trzecią część.
|
||||||
|
"""
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
raise HTTPException(422, "Oczekiwano obiektu JSON z raportem.")
|
||||||
|
try:
|
||||||
|
pdf = to_pdf(report)
|
||||||
|
except RenderError as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
except FileNotFoundError as e: # brak xelatex/rsvg w obrazie
|
||||||
|
raise HTTPException(503, f"Brak narzędzia w obrazie usługi render: {e}")
|
||||||
|
return Response(
|
||||||
|
content=pdf,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
"""Raportuje też obecność narzędzi — dzięki temu zepsuty obraz widać od razu,
|
||||||
|
a nie dopiero przy pierwszym raporcie."""
|
||||||
|
tools = toolchain_ready()
|
||||||
|
return {"status": "ok" if all(tools.values()) else "degraded",
|
||||||
|
"layer": "render", "tools": tools}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Uwierzytelnianie międzywarstwowe (LOG-32).
|
||||||
|
|
||||||
|
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
|
||||||
|
prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę
|
||||||
|
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
|
||||||
|
musi go przynieść w nagłówku X-Astrololo-Token.
|
||||||
|
|
||||||
|
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
|
||||||
|
starcie leci ostrzeżenie.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.security")
|
||||||
|
|
||||||
|
HEADER = "X-Astrololo-Token"
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
def token() -> str:
|
||||||
|
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
|
||||||
|
return os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(token())
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, layer: str) -> None:
|
||||||
|
if not enabled():
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
|
||||||
|
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _guard(request: Request, call_next):
|
||||||
|
if request.url.path in PUBLIC_PATHS or not enabled():
|
||||||
|
return await call_next(request)
|
||||||
|
got = request.headers.get(HEADER, "")
|
||||||
|
if not secrets.compare_digest(got, token()):
|
||||||
|
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
|
||||||
|
status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
# Szyfrowanie łącza z prezentacją (PRE-16/PRE-24): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Generowanie źródła LaTeX raportu (PRE-24).
|
||||||
|
|
||||||
|
Najważniejsza część to UCIECZKA znaków specjalnych. Kompilacji tu nie
|
||||||
|
uruchomimy (brak TeX Live w tym środowisku), ale to i tak nie ona jest tu
|
||||||
|
najbardziej krucha: błędna ucieczka nie wywala kompilacji, tylko po cichu psuje
|
||||||
|
treść — najgorszy możliwy wynik, bo PDF powstaje i wygląda poprawnie.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.latex import build, esc
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────── ucieczka znaków specjalnych ────────────────
|
||||||
|
|
||||||
|
def test_percent_is_escaped():
|
||||||
|
"""NAJGROŹNIEJSZY przypadek: `%` bez ucieczki komentuje resztę linii, więc
|
||||||
|
zdanie urywa się w środku, a PDF powstaje normalnie — tylko krótszy."""
|
||||||
|
out = esc("wzrost o 50% w tym okresie")
|
||||||
|
assert r"50\%" in out
|
||||||
|
assert "w tym okresie" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_backslash_is_escaped_first():
|
||||||
|
"""Odwrotny ukośnik musi iść pierwszy, inaczej zepsulibyśmy ucieczki
|
||||||
|
wstawione później (np. `\\%` zamieniłoby się w coś innego)."""
|
||||||
|
assert esc("a\\b") == r"a\textbackslash{}b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_specials_are_escaped():
|
||||||
|
for ch in ("$", "&", "#", "_", "%", "{", "}"):
|
||||||
|
assert ch not in esc(ch).replace("\\" + ch, ""), f"{ch} nie zostało ucieczone"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tilde_and_caret_use_commands():
|
||||||
|
assert esc("~") == r"\textasciitilde{}"
|
||||||
|
assert esc("^") == r"\textasciicircum{}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_polish_and_glyphs_pass_through():
|
||||||
|
"""XeLaTeX bierze unicode wprost — polskich znaków ani glifów nie ruszamy."""
|
||||||
|
text = "Zażółć gęślą jaźń ♄ ♓ ☉"
|
||||||
|
assert esc(text) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_handles_none_and_numbers():
|
||||||
|
assert esc(None) == ""
|
||||||
|
assert esc(42) == "42"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────── układ dokumentu ─────────────────────────
|
||||||
|
|
||||||
|
def _report(**over):
|
||||||
|
base = {
|
||||||
|
"person": "Jan Kowalski",
|
||||||
|
"data": {"date": "1984-04-30", "time": "11:20", "tz_offset": 2,
|
||||||
|
"lat": 50.0647, "lon": 19.945, "place": "Kraków",
|
||||||
|
"house_system": "whole_sign", "zodiac": "tropical"},
|
||||||
|
"natal": {"text": "Interpretacja natalna.\n\nDrugi akapit."},
|
||||||
|
"predictions": [
|
||||||
|
{"from_date": "2026-01-01", "to_date": "2026-03-31", "text": "Pierwszy kwartał."},
|
||||||
|
{"from_date": "2026-07-01", "to_date": "2026-09-30", "text": "Trzeci kwartał."},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
base.update(over)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_is_complete():
|
||||||
|
tex = build(_report())
|
||||||
|
assert tex.startswith(r"\documentclass")
|
||||||
|
assert tex.rstrip().endswith(r"\end{document}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_uses_unicode_engine_setup():
|
||||||
|
"""XeLaTeX + fontspec — bez tego polskie znaki i glify nie wyjdą."""
|
||||||
|
tex = build(_report())
|
||||||
|
assert r"\usepackage{fontspec}" in tex
|
||||||
|
assert r"\setmainfont" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_section_order_matches_the_brief():
|
||||||
|
"""Prośba partnerów: imię → dane → rysunek → natalna → predykcje."""
|
||||||
|
tex = build(_report(), wheel_pdf="wheel.pdf")
|
||||||
|
person = tex.index("Jan Kowalski")
|
||||||
|
data = tex.index("Data urodzenia")
|
||||||
|
wheel = tex.index("includegraphics")
|
||||||
|
natal = tex.index("Interpretacja natalna")
|
||||||
|
preds = tex.index("Predykcje okresowe")
|
||||||
|
assert person < data < wheel < natal < preds
|
||||||
|
|
||||||
|
|
||||||
|
def test_person_name_opens_the_document():
|
||||||
|
tex = build(_report())
|
||||||
|
body = tex.split(r"\begin{document}")[1]
|
||||||
|
assert body.index("Jan Kowalski") < 80, "imię ma być na samym początku"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_predictions_are_included():
|
||||||
|
tex = build(_report())
|
||||||
|
assert "2026-01-01" in tex and "2026-07-01" in tex
|
||||||
|
assert "Pierwszy kwartał" in tex and "Trzeci kwartał" in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_paragraphs_are_preserved():
|
||||||
|
"""Model oddziela akapity pustą linią — mają zostać akapitami, nie zlepkiem."""
|
||||||
|
tex = build(_report())
|
||||||
|
assert "Interpretacja natalna." in tex and "Drugi akapit." in tex
|
||||||
|
assert "Interpretacja natalna.\n\nDrugi akapit." in tex
|
||||||
|
|
||||||
|
|
||||||
|
def test_hostile_text_cannot_break_the_document():
|
||||||
|
"""Tekst od modelu jest wejściem z ZEWNĄTRZ — nie może wstrzyknąć polecenia
|
||||||
|
ani urwać dokumentu."""
|
||||||
|
nasty = r"100% \end{document} \input{/etc/passwd} $x_1$ #& {}"
|
||||||
|
tex = build(_report(natal={"text": nasty}))
|
||||||
|
body = tex.split(r"\begin{document}")[1]
|
||||||
|
assert body.count(r"\end{document}") == 1, "tekst urwał dokument"
|
||||||
|
assert r"\input{" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_parts_are_simply_absent():
|
||||||
|
"""Niekompletny materiał ma dać krótszy raport, nie wyjątek."""
|
||||||
|
tex = build({"person": "", "data": {}})
|
||||||
|
assert r"\begin{document}" in tex and r"\end{document}" in tex
|
||||||
|
assert "Raport astrologiczny" in tex # zapas, gdy brak imienia
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_is_optional():
|
||||||
|
assert "includegraphics" not in build(_report())
|
||||||
|
assert "includegraphics" in build(_report(), wheel_pdf="wheel.pdf")
|
||||||
Reference in New Issue
Block a user