diff --git a/admin/app.py b/admin/app.py index 7d946f8..a8357e9 100644 --- a/admin/app.py +++ b/admin/app.py @@ -19,13 +19,14 @@ from flask import ( # Admin stranice mini-app-ova (lagani moduli iz glavnog paketa, bez openai zavisnosti). sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from idriz.apps.kalendar import admin as _kalendar_admin # noqa: E402 from idriz.apps.obroci import admin as _obroci_admin # noqa: E402 from idriz.apps.raspored import admin as _raspored_admin # noqa: E402 from idriz.apps.superheroji import admin as _superheroji_admin # noqa: E402 from idriz.apps.vaktija import admin as _vaktija_admin # noqa: E402 # Svaki app-admin modul ima BP (Blueprint) i NAV (stavka lijevog menija). -ADMIN_APPS = [_superheroji_admin, _obroci_admin, _vaktija_admin, _raspored_admin] +ADMIN_APPS = [_superheroji_admin, _obroci_admin, _vaktija_admin, _raspored_admin, _kalendar_admin] # --- Konfiguracija ----------------------------------------------------------- diff --git a/idriz/apps/__init__.py b/idriz/apps/__init__.py index e556100..6544655 100644 --- a/idriz/apps/__init__.py +++ b/idriz/apps/__init__.py @@ -9,9 +9,10 @@ from .base import AppContext, AppSpec, AppTool # noqa: F401 def load_apps() -> list[AppSpec]: from .bojanka import manifest as bojanka + from .kalendar import manifest as kalendar from .obroci import manifest as obroci from .raspored import manifest as raspored from .sazetak import manifest as sazetak from .superheroji import manifest as superheroji from .vaktija import manifest as vaktija - return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC, vaktija.SPEC, raspored.SPEC] + return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC, vaktija.SPEC, raspored.SPEC, kalendar.SPEC] diff --git a/idriz/apps/kalendar/__init__.py b/idriz/apps/kalendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/idriz/apps/kalendar/admin.py b/idriz/apps/kalendar/admin.py new file mode 100644 index 0000000..d7587df --- /dev/null +++ b/idriz/apps/kalendar/admin.py @@ -0,0 +1,53 @@ +"""Admin za 'Kalendar': pregled, ručno dodavanje, izmjena i brisanje događaja. + +Flask Blueprint, bez openai/weasyprint zavisnosti (uvozi ga admin proces). +""" +import datetime as dt + +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from . import store + +BP = Blueprint("kalendar", __name__, template_folder="templates", url_prefix="/kalendar") +NAV = {"endpoint": "kalendar.index", "page": "kalendar", "title": "Kalendar", "icon": "📅"} + +BLANK_ROWS = 5 # praznih redova za dodavanje novih događaja + + +def _parse_iso(s: str) -> dt.date | None: + try: + return dt.date.fromisoformat((s or "").strip()) + except ValueError: + return None + + +@BP.route("/", methods=["GET", "POST"]) +def index(): + if request.method == "POST": + n = int(request.form.get("nrows", "0")) + dodano = izmijenjeno = obrisano = 0 + for i in range(n): + eid = request.form.get(f"r{i}_id", "").strip() + naziv = request.form.get(f"r{i}_naziv", "").strip() + datum = _parse_iso(request.form.get(f"r{i}_datum", "")) + vrijeme = request.form.get(f"r{i}_vrijeme", "") + emoji = request.form.get(f"r{i}_emoji", "") + if eid: # postojeći + if request.form.get(f"r{i}_del"): + if store.delete_event(int(eid)): + obrisano += 1 + elif naziv and datum: + store.update_event(int(eid), datum=datum, vrijeme=vrijeme, + naziv=naziv, emoji=emoji) + izmijenjeno += 1 + elif naziv and datum: # novi + store.add_event(naziv, datum, vrijeme, emoji) + dodano += 1 + flash(f"Sačuvano: {dodano} dodano, {izmijenjeno} izmijenjeno, {obrisano} obrisano.") + return redirect(url_for("kalendar.index")) + + today = dt.date.today().isoformat() + events = store.all_events() + rows = events + [{} for _ in range(BLANK_ROWS)] + return render_template("kalendar.html", rows=rows, nrows=len(rows), + danas=today, broj=len(events), active_page="kalendar") diff --git a/idriz/apps/kalendar/fonts/NotoEmoji-Regular.ttf b/idriz/apps/kalendar/fonts/NotoEmoji-Regular.ttf new file mode 100644 index 0000000..f3b247d Binary files /dev/null and b/idriz/apps/kalendar/fonts/NotoEmoji-Regular.ttf differ diff --git a/idriz/apps/kalendar/manifest.py b/idriz/apps/kalendar/manifest.py new file mode 100644 index 0000000..b77586b --- /dev/null +++ b/idriz/apps/kalendar/manifest.py @@ -0,0 +1,424 @@ +"""Mini-app 'Kalendar': lični događaji/podsjetnici (dodaj, obriši, pomjeri, pretraži, pregled, štampaj). + +Događaj je tačka u vremenu (datum + opciono vrijeme), bez trajanja. Datumi/vremena se +oslanjaju na sistemski sat (NTP). Upiti su inline (lokalna logika); štampanje je background. +Emoji se pamti uz događaj (za ljepšu štampu) ali se NIKAD ne izgovara. +""" +import datetime as dt +import logging +import re + +from idriz import printer +from idriz.util import slug + +from ..base import AppContext, AppSpec, AppTool +from . import store + +log = logging.getLogger("idriz.kalendar") + +_REL = {"danas": 0, "sutra": 1, "prekosutra": 2, "juce": -1, "jucer": -1, + "prekjuce": -2, "prekjucer": -2} +_REL_FRAZA = {0: "danas", 1: "sutra", 2: "prekosutra", -1: "jučer", -2: "prekjučer"} +_WD = { + "ponedjeljak": 0, "ponedjeljkom": 0, "utorak": 1, "utorkom": 1, + "srijeda": 2, "srijedu": 2, "srijedom": 2, "cetvrtak": 3, "cetvrtkom": 3, + "petak": 4, "petkom": 4, "subota": 5, "subotu": 5, "subotom": 5, + "nedjelja": 6, "nedjelju": 6, "nedjeljom": 6, "nedelja": 6, "nedeljom": 6, +} +_MJ = {store.norm(m): i for i, m in enumerate(store.MJESEC_NOM) if m} +_MJ.update({store.norm(m): i for i, m in enumerate(store.MJESEC_GEN) if m}) + + +# ---------- parsiranje datuma/mjeseca ---------- + +def _parse_datum(s: str, future_bias: bool = True) -> dt.date | None: + """Parsira jedan datum kako je rečen. Za goli 'DD.MM' bez godine i naziv dana: + future_bias => uzmi najbliže NADOLAZEĆE pojavljivanje.""" + today = dt.date.today() + s = (s or "").strip() + if not s: + return None + sn = store.norm(s) + if sn in _REL: + return today + dt.timedelta(days=_REL[sn]) + try: + return dt.date.fromisoformat(s) + except ValueError: + pass + m = re.match(r"^(\d{1,2})\.\s*(\d{1,2})\.?\s*(\d{2,4})?", s) + if m: + dd, mm = int(m.group(1)), int(m.group(2)) + yy = m.group(3) + year = int(yy) if yy else today.year + if year < 100: + year += 2000 + try: + d = dt.date(year, mm, dd) + except ValueError: + return None + if not yy and future_bias and d < today: + try: + d = dt.date(year + 1, mm, dd) + except ValueError: + pass + return d + for wname, wi in _WD.items(): + if sn.endswith(wname): + delta = (wi - today.weekday()) % 7 + if future_bias and delta == 0: + delta = 0 # danas ako je taj dan + return today + dt.timedelta(days=delta) + return None + + +def _parse_mjesec(s: str) -> tuple[int, int] | None: + """Vrati (godina, mjesec) iz 'novembar', '11', '11.2026', '2026-11' ili prazno=tekući.""" + today = dt.date.today() + s = (s or "").strip() + if not s: + return today.year, today.month + sn = store.norm(s) + for name, i in _MJ.items(): + if name in sn: + yg = re.search(r"(\d{4})", s) + year = int(yg.group(1)) if yg else today.year + if not yg and i < today.month: + year += 1 # bliži budući mjesec + return year, i + m = re.match(r"^(\d{4})[-.](\d{1,2})", s) # 2026-11 + if m: + return int(m.group(1)), int(m.group(2)) + m = re.match(r"^(\d{1,2})[.\-/](\d{4})", s) # 11.2026 + if m: + return int(m.group(2)), int(m.group(1)) + m = re.match(r"^(\d{1,2})$", s) # samo broj mjeseca + if m: + i = int(m.group(1)) + if 1 <= i <= 12: + year = today.year + (1 if i < today.month else 0) + return year, i + return None + + +# ---------- rečenice (bez emojija!) ---------- + +def _dan_fraza(d: dt.date) -> str: + fraza = f"u {store.DAN_AKUZATIV[d.weekday()]}, {d.day}. {store.MJESEC_GEN[d.month]}" + if d.year != dt.date.today().year: + fraza += f" {d.year}." + return fraza + + +def _vrijeme_fraza(vr: str) -> str: + return f" u {vr}" if vr else "" + + +def _ev_fraza(e: dt.date | dict) -> str: + d = dt.date.fromisoformat(e["datum"]) + return f"{e['naziv']} — {_dan_fraza(d)}{_vrijeme_fraza(e.get('vrijeme', ''))}" + + +def _lista_fraza(evs: list[dict]) -> str: + return "; ".join(_ev_fraza(e) for e in evs) + + +# ============================================================ +# ALAT: dodaj +# ============================================================ + +DODAJ_SCHEMA = { + "type": "function", + "name": "kalendar_dodaj", + "description": ("Zabilježi novi događaj u kalendar (podsjetnik). Pozovi kad korisnik " + "kaže da nešto ima/planira. Ako vrijeme nije rečeno a prirodno je da ga " + "ima (npr. termin, sastanak), prvo pitaj u koliko sati; ako je cjelodnevno " + "ili korisnik kaže da nema tačno vrijeme, ostavi vrijeme prazno."), + "parameters": { + "type": "object", + "properties": { + "naziv": {"type": "string", "description": "Kratak naziv događaja (npr. 'Zubar', 'Otvaranje Zdravo stopalo Novi Pazar')."}, + "datum": {"type": "string", "description": "Datum kako je rečen: '30.9.', '2026-09-30', 'sutra', 'u utorak'..."}, + "vrijeme": {"type": "string", "description": "Vrijeme 'HH:MM' (npr. '10:00'). Prazno ako nema tačno vrijeme."}, + "emoji": {"type": "string", "description": "JEDAN jednostavan emoji (objekat/simbol/životinja/lice) koji opisuje prirodu događaja (🦷 zubar, 🎉 otvaranje, 🎂 rođendan, ✈️ put, 👵 posjeta...). Izbjegavaj zastave i porodične/višestruke emojije (ne štampaju se jednobojno). Za ljepšu štampu; nikad se ne izgovara."}, + }, + "required": ["naziv", "datum"], + }, +} + + +async def kalendar_dodaj(ctx: AppContext, args: dict) -> str: + naziv = (args.get("naziv") or "").strip() + if not naziv: + return "Nedostaje naziv događaja — pitaj korisnika šta je događaj." + d = _parse_datum(args.get("datum", ""), future_bias=True) + if not d: + return f"Ne razumijem datum \"{args.get('datum')}\". Pitaj korisnika za datum (npr. '30. septembar')." + ev = store.add_event(naziv, d, args.get("vrijeme", ""), args.get("emoji", "")) + return (f'Zabilježeno. Reci korisniku prirodno na bosanskom (BEZ emojija): ' + f'"Zapisao sam {ev["naziv"]} {_dan_fraza(d)}{_vrijeme_fraza(ev["vrijeme"])}."') + + +# ============================================================ +# ALAT: obriši +# ============================================================ + +OBRISI_SCHEMA = { + "type": "function", + "name": "kalendar_obrisi", + "description": "Obriši događaj iz kalendara po nazivu (npr. 'obriši otvaranje Zdravog stopala'). Datum je opcion, pomaže kad ima više događaja istog naziva.", + "parameters": { + "type": "object", + "properties": { + "naziv": {"type": "string", "description": "Naziv ili dio naziva događaja koji se briše."}, + "datum": {"type": "string", "description": "Opciono, datum događaja (npr. '20.9.') ako ih ima više s istim nazivom."}, + }, + "required": ["naziv"], + }, +} + + +def _match(naziv: str, datum: str) -> list[dict]: + evs = store.find_by_name(naziv, "svi") + d = _parse_datum(datum, future_bias=False) if (datum or "").strip() else None + if d: + evs = [e for e in evs if e["datum"] == d.isoformat()] + return evs + + +async def kalendar_obrisi(ctx: AppContext, args: dict) -> str: + naziv = (args.get("naziv") or "").strip() + evs = _match(naziv, args.get("datum", "")) + if not evs: + return f'Nema događaja koji odgovara "{naziv}". Reci korisniku da ga nisi našao.' + if len(evs) > 1: + return (f"Ima više događaja koji odgovaraju: {_lista_fraza(evs)}. " + f"Pitaj korisnika koji tačno da obrišem (npr. po datumu).") + e = evs[0] + store.delete_event(e["id"]) + return f'Obrisano. Reci korisniku: "Obrisao sam {e["naziv"]} ({_dan_fraza(dt.date.fromisoformat(e["datum"]))})."' + + +# ============================================================ +# ALAT: pomjeri +# ============================================================ + +POMJERI_SCHEMA = { + "type": "function", + "name": "kalendar_pomjeri", + "description": "Pomjeri postojeći događaj na novi datum i/ili novo vrijeme (npr. 'pomjeri otvaranje Zdravog stopala na 25.9.').", + "parameters": { + "type": "object", + "properties": { + "naziv": {"type": "string", "description": "Naziv ili dio naziva događaja koji se pomjera."}, + "datum": {"type": "string", "description": "Opciono, trenutni datum događaja (za razlikovanje ako ih ima više)."}, + "novi_datum": {"type": "string", "description": "Novi datum (npr. '25.9.', 'sutra'). Prazno ako se mijenja samo vrijeme."}, + "novo_vrijeme": {"type": "string", "description": "Novo vrijeme 'HH:MM'. Prazno ako se mijenja samo datum."}, + }, + "required": ["naziv"], + }, +} + + +async def kalendar_pomjeri(ctx: AppContext, args: dict) -> str: + naziv = (args.get("naziv") or "").strip() + novi_datum = (args.get("novi_datum") or "").strip() + novo_vrijeme = (args.get("novo_vrijeme") or "").strip() + if not novi_datum and not novo_vrijeme: + return "Nije rečeno na koji datum ni vrijeme da pomjerim — pitaj korisnika." + evs = _match(naziv, args.get("datum", "")) + if not evs: + return f'Nema događaja koji odgovara "{naziv}". Reci korisniku da ga nisi našao.' + if len(evs) > 1: + return (f"Ima više događaja koji odgovaraju: {_lista_fraza(evs)}. " + f"Pitaj korisnika koji tačno da pomjerim.") + e = evs[0] + d = None + if novi_datum: + d = _parse_datum(novi_datum, future_bias=True) + if not d: + return f'Ne razumijem novi datum "{novi_datum}". Pitaj korisnika.' + store.update_event(e["id"], datum=d, + vrijeme=(novo_vrijeme if novo_vrijeme else None)) + nd = d or dt.date.fromisoformat(e["datum"]) + nv = store.clean_time(novo_vrijeme) if novo_vrijeme else e.get("vrijeme", "") + return (f'Pomjereno. Reci korisniku: "{e["naziv"]} je sada ' + f'{_dan_fraza(nd)}{_vrijeme_fraza(nv)}."') + + +# ============================================================ +# ALAT: pretraga +# ============================================================ + +PRETRAGA_SCHEMA = { + "type": "function", + "name": "kalendar_pretraga", + "description": ("Nađi kad je neki događaj po nazivu. Za buduće ('kad mi je zubar?') koristi " + "smjer='naredni'; za prošlost ('kad sam zadnji put posjetio nanu?') " + "smjer='prosli'; za sve smjer='svi'."), + "parameters": { + "type": "object", + "properties": { + "naziv": {"type": "string", "description": "Naziv ili dio naziva događaja koji se traži."}, + "smjer": {"type": "string", "enum": ["naredni", "prosli", "svi"], + "description": "'naredni' = nadolazeći (default za 'kad mi je...'), 'prosli' = u prošlosti (za 'zadnji put'), 'svi'."}, + }, + "required": ["naziv"], + }, +} + + +async def kalendar_pretraga(ctx: AppContext, args: dict) -> str: + naziv = (args.get("naziv") or "").strip() + smjer = (args.get("smjer") or "naredni").strip().lower() + if smjer not in ("naredni", "prosli", "svi"): + smjer = "naredni" + evs = store.find_by_name(naziv, smjer) + if not evs: + opis = {"naredni": "nadolazećih", "prosli": "prošlih", "svi": ""}[smjer] + return f'Nema {opis} događaja za "{naziv}". Reci korisniku da ništa nisi našao.' + if smjer in ("naredni", "prosli"): + e = evs[0] + rijec = "Sljedeći" if smjer == "naredni" else "Zadnji put" + if smjer == "naredni": + return f'Reci korisniku prirodno: "{e["naziv"]} ti je {_dan_fraza(dt.date.fromisoformat(e["datum"]))}{_vrijeme_fraza(e.get("vrijeme",""))}."' + return f'Reci korisniku prirodno: "{e["naziv"]} je bilo {_dan_fraza(dt.date.fromisoformat(e["datum"]))}{_vrijeme_fraza(e.get("vrijeme",""))}."' + return f'Reci korisniku prirodno na bosanskom, sažeto: "{_lista_fraza(evs)}"' + + +# ============================================================ +# ALAT: pregled (dan / sedmica / mjesec) +# ============================================================ + +PREGLED_SCHEMA = { + "type": "function", + "name": "kalendar_pregled", + "description": ("Nabroji događaje za dan, sedmicu ili mjesec ('šta imam u utorak?', " + "'koje događaje imam ovaj mjesec?', 'šta imam ove sedmice?')."), + "parameters": { + "type": "object", + "properties": { + "period": {"type": "string", "enum": ["dan", "sedmica", "mjesec"], + "description": "Raspon pregleda."}, + "kada": {"type": "string", "description": "Za 'dan': 'danas'/'sutra'/'u utorak'/'30.9.'. Za 'sedmica': neki dan te sedmice ('ova', 'sljedeća' ili datum). Za 'mjesec': 'novembar', 'ovaj mjesec' ili '2026-11'. Prazno = tekući."}, + }, + "required": ["period"], + }, +} + + +def _raspon(period: str, kada: str) -> tuple[dt.date, dt.date, str] | None: + today = dt.date.today() + kada = (kada or "").strip() + if period == "dan": + d = _parse_datum(kada, future_bias=True) or today + return d, d, _dan_fraza(d) + if period == "sedmica": + kn = store.norm(kada) + base = today + if any(k in kn for k in ("sljedec", "naredn", "iduc")): + base = today + dt.timedelta(days=7) + elif kn and "ova" not in kn and "ovu" not in kn: + base = _parse_datum(kada, future_bias=True) or today + mon = base - dt.timedelta(days=base.weekday()) + sun = mon + dt.timedelta(days=6) + opis = f"sedmica {mon.day}.{mon.month}. – {sun.day}.{sun.month}." + return mon, sun, opis + if period == "mjesec": + ym = _parse_mjesec(kada) + if not ym: + return None + import calendar + year, month = ym + start = dt.date(year, month, 1) + end = dt.date(year, month, calendar.monthrange(year, month)[1]) + opis = f"{store.MJESEC_NOM[month]} {year}." + return start, end, opis + return None + + +async def kalendar_pregled(ctx: AppContext, args: dict) -> str: + period = (args.get("period") or "dan").strip().lower() + if period not in ("dan", "sedmica", "mjesec"): + period = "dan" + r = _raspon(period, args.get("kada", "")) + if not r: + return f"Ne razumijem period \"{args.get('kada')}\". Pitaj korisnika da ponovi." + start, end, opis = r + evs = store.events_between(start, end) + if not evs: + return f'Reci korisniku: "Za {opis} nemaš nijedan događaj u kalendaru."' + return (f'Nabroji korisniku prirodno na bosanskom (BEZ emojija), za {opis}: ' + f'"{_lista_fraza(evs)}"') + + +# ============================================================ +# ALAT: štampaj (background) +# ============================================================ + +STAMPAJ_SCHEMA = { + "type": "function", + "name": "kalendar_stampaj", + "description": ("Odštampa kalendar na A4 (jedna stranica). period='mjesec' => mjesečni " + "grid; period='dan' => dnevna lista. Pozovi tek nakon potvrde korisnika."), + "parameters": { + "type": "object", + "properties": { + "period": {"type": "string", "enum": ["dan", "mjesec"], + "description": "'mjesec' (grid cijelog mjeseca) ili 'dan' (jedan dan)."}, + "kada": {"type": "string", "description": "Za 'mjesec': 'novembar' ili '2026-11'. Za 'dan': 'sutra'/'30.9.'. Prazno = tekući mjesec/danas."}, + }, + "required": ["period"], + }, +} + + +async def kalendar_stampaj(ctx: AppContext, args: dict) -> str: + from . import render # lijeni uvoz (weasyprint je težak) + cfg = ctx.cfg + period = (args.get("period") or "mjesec").strip().lower() + kada = args.get("kada", "") + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + + if period == "dan": + d = _parse_datum(kada, future_bias=True) or dt.date.today() + out = cfg.out_dir / f"{stamp}-kalendar-dan-{d.isoformat()}.pdf" + render.render_day(d, out) + opis = f"dnevni kalendar za {_dan_fraza(d)}" + else: + ym = _parse_mjesec(kada) + if not ym: + return f"Ne razumijem koji mjesec (\"{kada}\"). Pitaj korisnika." + year, month = ym + out = cfg.out_dir / f"{stamp}-kalendar-mjesec-{year}-{month:02d}.pdf" + render.render_month(year, month, out) + opis = f"kalendar za {store.MJESEC_NOM[month]} {year}." + + await printer.print_file(out, cfg.printer, cfg.dry_run, fit=True) + return f"Odštampan {opis}. Reci korisniku da uzme papir iz printera." + + +# ============================================================ + +INSTRUCTIONS = """KALENDAR (alati: kalendar_dodaj, kalendar_obrisi, kalendar_pomjeri, kalendar_pretraga, kalendar_pregled, kalendar_stampaj): vodiš lični kalendar događaja/podsjetnika. Događaj je tačka u vremenu (datum + opciono vrijeme), bez trajanja. +- DODAVANJE (kalendar_dodaj): kad korisnik kaže da nešto ima/planira ("30.9. imam zubara u 10", "20.9. se otvara Zdravo stopalo Novi Pazar"). Ako vrijeme prirodno postoji a nije rečeno, prvo pitaj "u koliko sati?"; ako je cjelodnevno, ostavi ga prazno. Uz svaki događaj dodaj JEDAN prikladan emoji (parametar emoji) prema prirodi događaja — služi samo za ljepšu štampu. +- BRISANJE (kalendar_obrisi) i POMJERANJE (kalendar_pomjeri): po nazivu; ako ima više istih, alat će tražiti da pitaš koji (po datumu). +- PRETRAGA (kalendar_pretraga): "kad mi je zubar?" (smjer=naredni), "kad sam zadnji put posjetio nanu?" (smjer=prosli). +- PREGLED (kalendar_pregled): "šta imam u utorak?" (dan), "šta imam ove sedmice?" (sedmica), "koje događaje imam ovaj mjesec?" (mjesec). +- ŠTAMPA (kalendar_stampaj): "odštampaj kalendar za novembar" (period=mjesec) ili dnevni (period=dan). Prije štampanja potvrdi ("Da odštampam kalendar za novembar?"). +- Alati vraćaju gotove bosanske rečenice — izgovori ih prirodno. VAŽNO: NIKAD ne izgovaraj emoji, on je samo za štampu. Ne izmišljaj događaje ni vremena; oslanjaj se na ono što alat vrati.""" + + +SPEC = AppSpec( + name="kalendar", + title="Kalendar", + instructions=INSTRUCTIONS, + greeting="voditi kalendar (zapisati i podsjetiti na događaje, odštampati mjesec)", + tools=[ + AppTool(DODAJ_SCHEMA, kalendar_dodaj, "inline"), + AppTool(OBRISI_SCHEMA, kalendar_obrisi, "inline"), + AppTool(POMJERI_SCHEMA, kalendar_pomjeri, "inline"), + AppTool(PRETRAGA_SCHEMA, kalendar_pretraga, "inline"), + AppTool(PREGLED_SCHEMA, kalendar_pregled, "inline"), + AppTool(STAMPAJ_SCHEMA, kalendar_stampaj, "background"), + ], +) diff --git a/idriz/apps/kalendar/render.py b/idriz/apps/kalendar/render.py new file mode 100644 index 0000000..8ca7dc0 --- /dev/null +++ b/idriz/apps/kalendar/render.py @@ -0,0 +1,185 @@ +"""PDF za štampu kalendara (WeasyPrint): mjesečni grid (A4 pejzaž) i dnevna lista (A4 uspravno). + +Uvozi ga samo asistent (weasyprint je težak) — ne admin. Lagan mjesec staje na jednu stranu; +pretrpan se čisto lomi po sedmicama (zaglavlje se ponovi). Dnevna lista smanjuje font da stane. +Emoji: bundlani MONOHROMATSKI Noto Emoji (vektorski) — čist crni obris, savršen za laser. +""" +import calendar +import datetime as dt +import html +import logging +from pathlib import Path + +from weasyprint import HTML +from weasyprint.text.fonts import FontConfiguration + +from . import store + +log = logging.getLogger("idriz.kalendar.render") + +# Emoji font se koristi SAMO na emoji znaku (span), da ne "otme" cifre/tekst. +# Bundlan uz app (vektorski, jednobojni) i ubačen preko @font-face. +EMOJI_FONT = "IdrizEmoji" +_VS16 = chr(0xFE0F) # emoji-prezentacija (u boji) — uklanjamo +_VS15 = chr(0xFE0E) # tekst-prezentacija (jednobojno) — dodajemo +_EMOJI_TTF = (Path(__file__).parent / "fonts" / "NotoEmoji-Regular.ttf").resolve() +FONTFACE = ("@font-face { font-family: 'IdrizEmoji'; " + f"src: url('{_EMOJI_TTF.as_uri()}'); }}") +# FontConfiguration je OBAVEZAN da WeasyPrint uopšte primijeni @font-face (inače +# koristi samo sistemske fontove pa emoji padnu na color-emoji). +_FONT_CONFIG = FontConfiguration() + +_ZWJ = chr(0x200D) # zero-width joiner (spaja dijelove složenih emojija) + + +def _covered() -> set[int]: + """Kodne tačke koje bundlani font ima (da ne renderujemo nešto što fali — + npr. zastave — i time izbjegnemo ružan color-fallback).""" + try: + from fontTools.ttLib import TTFont + f = TTFont(str(_EMOJI_TTF)) + cps: set[int] = set() + for t in f["cmap"].tables: + cps.update(t.cmap.keys()) + return cps + except Exception: # noqa: BLE001 + return set() + + +_COVERED = _covered() + + +def _emoji_span(emoji: str, cls: str = "") -> str: + """Emoji u vlastitom (jednobojnom) fontu + VS15 da se traži TEKSTUALNA prezentacija; + inače Pango bira sistemski color-emoji font (loše metrike, u boji na laseru). + Ako font nema neki znak (npr. zastave), emoji se izostavi (bez ružnog fallbacka).""" + e = (emoji or "").replace(_VS16, "").replace(_VS15, "").strip() + if not e or _ZWJ in e: + # ZWJ-sekvence (porodica, zanimanja...) nemaju jednobojni ligaturni glif — + # dijelovi bi "odletjeli" preko color-fallbacka; radije ih izostavi. + return "" + # zastave (regionalni indikatori) nemaju jednobojni glif — izostavi + if any(0x1F1E6 <= ord(ch) <= 0x1F1FF for ch in e): + return "" + if _COVERED and any(ord(ch) not in _COVERED for ch in e): + return "" + c = f" class='{cls}'" if cls else "" + return (f"" + f"{html.escape(e)}{_VS15} ") + + +def _ev_line(e: dict) -> str: + vr = (e.get("vrijeme") + " ") if e.get("vrijeme") else "" + return f"{_emoji_span(e.get('emoji',''))}{html.escape(vr)}{html.escape(e['naziv'])}" + + +# ---------- mjesečni grid ---------- + +_MONTH_CSS = """ +@page {{ size: A4 landscape; margin: 9mm; + @bottom-center {{ content: "{naslov} • strana " counter(page) " / " counter(pages); + font-family: "DejaVu Sans", sans-serif; font-size: 8pt; color: #888; }} }} +body {{ font-family: "DejaVu Sans", "Noto Sans", sans-serif; color: #111; }} +h1 {{ font-size: 19pt; margin: 0 0 5pt 0; text-align: center; }} +table {{ width: 100%; border-collapse: collapse; table-layout: fixed; }} +thead {{ display: table-header-group; }} /* zaglavlje se ponovi na svakoj strani */ +th {{ font-size: 11pt; padding: 3pt 0; border: 1pt solid #999; background: #eef0f2; }} +th.we {{ background: #f3dede; }} +td {{ border: 1pt solid #bbb; vertical-align: top; padding: 2pt 3pt; width: 14.28%; + height: {rowh}mm; }} +td.we {{ background: #fbeeee; }} +td.off {{ background: #fafafa; }} +tr {{ page-break-inside: avoid; }} /* sedmica se ne lomi na pola */ +.dnum {{ font-size: 11pt; font-weight: bold; color: #333; float: right; }} +td.today .dnum {{ background: #c62828; color: #fff; border-radius: 50%; padding: 0 4pt; }} +.evs {{ clear: both; font-size: {evsize}pt; line-height: 1.22; margin-top: 13pt; }} +.evs div {{ margin-bottom: 1.5pt; }} +""" + + +def render_month(year: int, month: int, out: Path) -> Path: + events = store.events_between(dt.date(year, month, 1), + dt.date(year, month, calendar.monthrange(year, month)[1])) + by_day: dict[int, list[dict]] = {} + for e in events: + by_day.setdefault(int(e["datum"][8:10]), []).append(e) + + today = dt.date.today() + cal = calendar.Calendar(firstweekday=0) # ponedjeljak + weeks = cal.monthdayscalendar(year, month) # liste od 7, 0 = van mjeseca + + header = "".join( + f"
Nema događaja ovog dana.
" + + for size in (18, 16, 14, 12, 11): + css = _DAY_CSS.format(size=size) + doc = HTML(string=f"" + f"{{ broj }} događaj(a). Prazni redovi na dnu služe za dodavanje novih.
+ +{% endblock %}