From f17a16a34d9b0a0b7f2f314e2404cfd3761814ca Mon Sep 17 00:00:00 2001 From: idriz Date: Fri, 11 Sep 2026 18:48:59 +0200 Subject: [PATCH] Nova aplikacija "Namaz" (vaktija) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vremena dnevnih namaza (sabah, podne, ikindija, akšam, jacija) sa api.vaktija.ba. - idriz/apps/vaktija/store.py: izbor lokacije + bundlana lista (lokacije.json, 118 lokacija; id=indeks). Sarajevo default. Bez openai/requests (uvozi ga admin). - client.py: dohvat vakat[] po (lokacija, datum) sa keširanjem; API vraća lokalna vremena (sam vodi DST). vakat[1] je izlazak sunca (nije namaz), preskočen. - manifest.py: alat namaz_vrijeme(koji): 'sljedeci' = prvi nadolazeći (ili sutra sabah ako su svi prošli); ili konkretan namaz (danas ako nadolazi, inače sutra). Bosanska gramatika za "za X sati/minuta" i rod (prošao/prošlo/prošla). - admin.py + templates/vaktija.html: dropdown svih lokacija, Sarajevo default. - Registrovano u load_apps() i ADMIN_APPS. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW --- admin/app.py | 3 +- idriz/apps/__init__.py | 3 +- idriz/apps/vaktija/__init__.py | 0 idriz/apps/vaktija/admin.py | 25 +++++ idriz/apps/vaktija/client.py | 67 +++++++++++ idriz/apps/vaktija/lokacije.json | 1 + idriz/apps/vaktija/manifest.py | 130 ++++++++++++++++++++++ idriz/apps/vaktija/store.py | 61 ++++++++++ idriz/apps/vaktija/templates/vaktija.html | 30 +++++ 9 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 idriz/apps/vaktija/__init__.py create mode 100644 idriz/apps/vaktija/admin.py create mode 100644 idriz/apps/vaktija/client.py create mode 100644 idriz/apps/vaktija/lokacije.json create mode 100644 idriz/apps/vaktija/manifest.py create mode 100644 idriz/apps/vaktija/store.py create mode 100644 idriz/apps/vaktija/templates/vaktija.html diff --git a/admin/app.py b/admin/app.py index dc4e1a9..b38a927 100644 --- a/admin/app.py +++ b/admin/app.py @@ -21,9 +21,10 @@ from flask import ( sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from idriz.apps.obroci import admin as _obroci_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] +ADMIN_APPS = [_superheroji_admin, _obroci_admin, _vaktija_admin] # --- Konfiguracija ----------------------------------------------------------- diff --git a/idriz/apps/__init__.py b/idriz/apps/__init__.py index d406157..81de349 100644 --- a/idriz/apps/__init__.py +++ b/idriz/apps/__init__.py @@ -12,4 +12,5 @@ def load_apps() -> list[AppSpec]: from .obroci import manifest as obroci from .sazetak import manifest as sazetak from .superheroji import manifest as superheroji - return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC] + from .vaktija import manifest as vaktija + return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC, vaktija.SPEC] diff --git a/idriz/apps/vaktija/__init__.py b/idriz/apps/vaktija/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/idriz/apps/vaktija/admin.py b/idriz/apps/vaktija/admin.py new file mode 100644 index 0000000..935071c --- /dev/null +++ b/idriz/apps/vaktija/admin.py @@ -0,0 +1,25 @@ +"""Admin za 'Namaz': izbor lokacije iz dropdowna (Sarajevo default). Bez openai.""" +from flask import Blueprint, flash, redirect, render_template, request, url_for + +from . import store + +BP = Blueprint("vaktija", __name__, template_folder="templates", url_prefix="/vaktija") +NAV = {"endpoint": "vaktija.index", "page": "vaktija", "title": "Namaz", "icon": "🕌"} + + +@BP.route("/", methods=["GET", "POST"]) +def index(): + if request.method == "POST": + try: + saved = store.set_location_id(int(request.form.get("lokacija", ""))) + flash(f"Lokacija postavljena: {store.location_name(saved)}.") + except (ValueError, TypeError): + flash("Neispravna lokacija.") + return redirect(url_for("vaktija.index")) + + return render_template( + "vaktija.html", + lokacije=store.locations(), + odabrana=store.get_location_id(), + active_page="vaktija", + ) diff --git a/idriz/apps/vaktija/client.py b/idriz/apps/vaktija/client.py new file mode 100644 index 0000000..a75cd31 --- /dev/null +++ b/idriz/apps/vaktija/client.py @@ -0,0 +1,67 @@ +"""Dohvat vremena namaza sa api.vaktija.ba, sa keširanjem po (lokacija, datum). + +Koristi requests (uvozi ga samo asistent, ne admin). API vraća lokalna vremena +(sam vodi računa o ljetnom/zimskom vremenu). vakat[] ima 6 vrijednosti: +[sabah/zora, izlazak sunca, podne, ikindija, akšam, jacija]. +""" +import datetime as dt +import json +import logging +import os +import tempfile +import threading +from pathlib import Path + +import requests + +log = logging.getLogger("idriz.vaktija") + +CACHE_PATH = Path(os.environ.get( + "IDRIZ_VAKTIJA_CACHE_PATH", str(Path.home() / ".local/share/idriz/vaktija-cache.json"))) +API = "https://api.vaktija.ba/vaktija/v1" +_lock = threading.Lock() + + +def _load_cache() -> dict: + try: + return json.loads(CACHE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def _save_cache(cache: dict) -> None: + # drži zadnjih ~60 dana da fajl ne raste + if len(cache) > 60: + for k in sorted(cache.keys())[:-60]: + del cache[k] + with _lock: + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(CACHE_PATH.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(cache, f, ensure_ascii=False) + os.replace(tmp, CACHE_PATH) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + +def fetch_vakat(loc_id: int, date: dt.date) -> list[str] | None: + """Vrati listu od 6 vremena ("H:MM") za datu lokaciju i datum, ili None ako ne uspije.""" + key = f"{loc_id}-{date.isoformat()}" + cache = _load_cache() + if key in cache: + return cache[key] + url = f"{API}/{loc_id}/{date.year}/{date.month}/{date.day}" + try: + r = requests.get(url, timeout=10, headers={"User-Agent": "Idriz/0.1"}) + r.raise_for_status() + vakat = r.json().get("vakat") + if isinstance(vakat, list) and len(vakat) == 6: + cache[key] = vakat + _save_cache(cache) + return vakat + log.warning("neočekivan odgovor vaktije za %s: %r", key, vakat) + except Exception: # noqa: BLE001 + log.warning("vaktija API nije uspio za %s", key) + return None diff --git a/idriz/apps/vaktija/lokacije.json b/idriz/apps/vaktija/lokacije.json new file mode 100644 index 0000000..9aac036 --- /dev/null +++ b/idriz/apps/vaktija/lokacije.json @@ -0,0 +1 @@ +["Banovići","Banja Luka","Bihać","Bijeljina","Bileća","Bosanski Brod","Bosanska Dubica","Bosanska Gradiška","Bosansko Grahovo","Bosanska Krupa","Bosanski Novi","Bosanski Petrovac","Bosanski Šamac","Bratunac","Brčko","Breza","Bugojno","Busovača","Bužim","Cazin","Čajniče","Čapljina","Čelić","Čelinac","Čitluk","Derventa","Doboj","Donji Vakuf","Drvar","Foča","Fojnica","Gacko","Glamoč","Goražde","Gornji Vakuf","Gračanica","Gradačac","Grude","Hadžići","Han-Pijesak","Hlivno","Ilijaš","Jablanica","Jajce","Kakanj","Kalesija","Kalinovik","Kiseljak","Kladanj","Ključ","Konjic","Kotor-Varoš","Kreševo","Kupres","Laktaši","Lopare","Lukavac","Ljubinje","Ljubuški","Maglaj","Modriča","Mostar","Mrkonjić-Grad","Neum","Nevesinje","Novi Travnik","Odžak","Olovo","Orašje","Pale","Posušje","Prijedor","Prnjavor","Prozor","Rogatica","Rudo","Sanski Most","Sarajevo","Skender-Vakuf","Sokolac","Srbac","Srebrenica","Srebrenik","Stolac","Šekovići","Šipovo","Široki Brijeg","Teslić","Tešanj","Tomislav-Grad","Travnik","Trebinje","Trnovo","Tuzla","Ugljevik","Vareš","Velika Kladuša","Visoko","Višegrad","Vitez","Vlasenica","Zavidovići","Zenica","Zvornik","Žepa","Žepče","Živinice","Bijelo Polje","Gusinje","Nova Varoš","Novi Pazar","Plav","Pljevlja","Priboj","Prijepolje","Rožaje","Sjenica","Tutin"] \ No newline at end of file diff --git a/idriz/apps/vaktija/manifest.py b/idriz/apps/vaktija/manifest.py new file mode 100644 index 0000000..6e921f9 --- /dev/null +++ b/idriz/apps/vaktija/manifest.py @@ -0,0 +1,130 @@ +"""Mini-app 'Namaz': vremena dnevnih namaza (vaktija) za izabranu lokaciju. + +Odgovara na "kad je sljedeći namaz?" i "kad je ikindija/sabah/..." (danas ako +nadolazi, inače sutra). Podaci sa api.vaktija.ba (v. client.py); lokacija u adminu. +""" +import asyncio +import datetime as dt +import logging + +from ..base import AppContext, AppSpec, AppTool +from . import client, store + +log = logging.getLogger("idriz.vaktija") + +# indeks u vakat[] po namazu (index 1 = izlazak sunca, nije namaz) +IDX = {"sabah": 0, "podne": 2, "ikindija": 3, "aksam": 4, "jacija": 5} +PRETTY = {"sabah": "sabah", "podne": "podne", "ikindija": "ikindija", "aksam": "akšam", "jacija": "jacija"} +REDOSLIJED = ["sabah", "podne", "ikindija", "aksam", "jacija"] +# rod za "već prošao/prošlo/prošla" +PROSAO = {"sabah": "prošao", "podne": "prošlo", "ikindija": "prošla", "aksam": "prošao", "jacija": "prošla"} + +INSTRUCTIONS = """NAMAZ / VAKTIJA (alat: namaz_vrijeme): kazuješ vremena dnevnih namaza (sabah, podne, ikindija, akšam, jacija) za postavljenu lokaciju. +- Kad dijete pita "kad je sljedeći namaz?", pozovi namaz_vrijeme sa koji="sljedeci". +- Kad pita za određeni namaz ("kad je ikindija?", "kad je sabah?"), pozovi namaz_vrijeme sa tim imenom (sabah, podne, ikindija, aksam, jacija). Alat sam odlučuje odnosi li se na današnji (ako tek nadolazi) ili sutrašnji namaz (ako je današnji već prošao). +- Alat vraća gotovu rečenicu na bosanskom — izgovori je djetetu prirodno, bez izmišljanja vremena. +- Ako alat kaže da nema podataka, reci to iskreno (možda nema interneta).""" + +SCHEMA = { + "type": "function", + "name": "namaz_vrijeme", + "description": "Vrijeme namaza za postavljenu lokaciju. koji='sljedeci' za sljedeći namaz, ili ime namaza (sabah/podne/ikindija/aksam/jacija) za konkretan (danas ako nadolazi, inače sutra).", + "parameters": { + "type": "object", + "properties": { + "koji": {"type": "string", "enum": ["sljedeci", "sabah", "podne", "ikindija", "aksam", "jacija"], + "description": "'sljedeci' ili ime namaza."}, + }, + "required": ["koji"], + }, +} + + +def _sat_rijec(n: int) -> str: + if 11 <= n % 100 <= 14: + return "sati" + d = n % 10 + return "sat" if d == 1 else ("sata" if 2 <= d <= 4 else "sati") + + +def _min_rijec(n: int) -> str: + if 11 <= n % 100 <= 14: + return "minuta" + d = n % 10 + return "minutu" if d == 1 else ("minute" if 2 <= d <= 4 else "minuta") + + +def _za(delta_min: int) -> str: + if delta_min < 1: + return "za manje od minute" + h, m = divmod(delta_min, 60) + if h and m: + return f"za {h} {_sat_rijec(h)} i {m} {_min_rijec(m)}" + if h: + return f"za {h} {_sat_rijec(h)}" + return f"za {m} {_min_rijec(m)}" + + +def _dt(vakat: list[str], idx: int, date: dt.date) -> dt.datetime: + h, m = vakat[idx].split(":") + return dt.datetime(date.year, date.month, date.day, int(h), int(m)) + + +def _hhmm(t: dt.datetime) -> str: + return t.strftime("%H:%M") + + +async def namaz_vrijeme(ctx: AppContext, args: dict) -> str: + koji = (args.get("koji") or "sljedeci").strip().lower() + loc_id = store.get_location_id() + loc = store.location_name(loc_id) + now = dt.datetime.now() + today = now.date() + + vakat = await asyncio.to_thread(client.fetch_vakat, loc_id, today) + if not vakat: + return f"Trenutno ne mogu dobiti vremena namaza za {loc} (možda nema interneta). Reci to djetetu iskreno." + + if koji == "sljedeci": + for name in REDOSLIJED: + t = _dt(vakat, IDX[name], today) + if t > now: + delta = int((t - now).total_seconds() // 60) + recenica = f"Sljedeći namaz je {PRETTY[name]}, u {_hhmm(t)}, {_za(delta)}." + return f'Reci djetetu prirodno na bosanskom: "{recenica}"' + # svi današnji prošli -> sutra sabah + tomorrow = today + dt.timedelta(days=1) + vsutra = await asyncio.to_thread(client.fetch_vakat, loc_id, tomorrow) + if not vsutra: + return "Danas su svi namazi prošli, a ne mogu dobiti sutrašnja vremena. Reci to djetetu." + t = _dt(vsutra, IDX["sabah"], tomorrow) + delta = int((t - now).total_seconds() // 60) + recenica = f"Svi današnji namazi su prošli. Sljedeći je sabah sutra, u {_hhmm(t)}, {_za(delta)}." + return f'Reci djetetu prirodno na bosanskom: "{recenica}"' + + if koji not in IDX: + return "Nisam siguran koji namaz — pitaj dijete da ponovi (sabah, podne, ikindija, akšam ili jacija)." + + t_today = _dt(vakat, IDX[koji], today) + if t_today > now: + delta = int((t_today - now).total_seconds() // 60) + recenica = f"{PRETTY[koji].capitalize()} je danas u {_hhmm(t_today)}, {_za(delta)}." + return f'Reci djetetu prirodno na bosanskom: "{recenica}"' + # danas prošao -> sutra + tomorrow = today + dt.timedelta(days=1) + vsutra = await asyncio.to_thread(client.fetch_vakat, loc_id, tomorrow) + if not vsutra: + return f"{PRETTY[koji].capitalize()} je danas već {PROSAO[koji]}, a ne mogu dobiti sutrašnje vrijeme. Reci to djetetu." + t = _dt(vsutra, IDX[koji], tomorrow) + delta = int((t - now).total_seconds() // 60) + recenica = f"{PRETTY[koji].capitalize()} je danas već {PROSAO[koji]}. Sutra je u {_hhmm(t)}, {_za(delta)}." + return f'Reci djetetu prirodno na bosanskom: "{recenica}"' + + +SPEC = AppSpec( + name="vaktija", + title="Namaz", + instructions=INSTRUCTIONS, + greeting="reći kad je koji namaz (vaktija)", + tools=[AppTool(SCHEMA, namaz_vrijeme, "inline")], +) diff --git a/idriz/apps/vaktija/store.py b/idriz/apps/vaktija/store.py new file mode 100644 index 0000000..295c0da --- /dev/null +++ b/idriz/apps/vaktija/store.py @@ -0,0 +1,61 @@ +"""Skladište za app 'Namaz': izbor lokacije + bundlana lista lokacija. + +Bez openai/requests (uvozi ga i admin). Lista lokacija je bundlana (lokacije.json); +id lokacije = indeks u toj listi (kako radi api.vaktija.ba). +""" +import json +import os +import tempfile +import threading +from pathlib import Path + +VAKTIJA_CONFIG_PATH = Path(os.environ.get( + "IDRIZ_VAKTIJA_CONFIG_PATH", str(Path.home() / ".config/idriz/vaktija.json"))) + +DEFAULT_ID = 77 # Sarajevo +_LOCATIONS_FILE = Path(__file__).with_name("lokacije.json") +_lock = threading.Lock() + +try: + _LOCATIONS: list[str] = json.loads(_LOCATIONS_FILE.read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError): + _LOCATIONS = ["Sarajevo"] + + +def locations() -> list[dict]: + """Sve lokacije kao [{'id': i, 'naziv': ...}], abecedno po nazivu.""" + return sorted(({"id": i, "naziv": n} for i, n in enumerate(_LOCATIONS)), + key=lambda x: x["naziv"].lower()) + + +def valid_id(loc_id: int) -> bool: + return isinstance(loc_id, int) and 0 <= loc_id < len(_LOCATIONS) + + +def location_name(loc_id: int) -> str: + return _LOCATIONS[loc_id] if valid_id(loc_id) else "Sarajevo" + + +def get_location_id() -> int: + try: + data = json.loads(VAKTIJA_CONFIG_PATH.read_text(encoding="utf-8")) + loc = int(data.get("lokacija_id", DEFAULT_ID)) + except (OSError, ValueError, json.JSONDecodeError): + return DEFAULT_ID + return loc if valid_id(loc) else DEFAULT_ID + + +def set_location_id(loc_id: int) -> int: + if not valid_id(loc_id): + loc_id = DEFAULT_ID + with _lock: + VAKTIJA_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(VAKTIJA_CONFIG_PATH.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({"lokacija_id": loc_id}, f, ensure_ascii=False, indent=2) + os.replace(tmp, VAKTIJA_CONFIG_PATH) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + return loc_id diff --git a/idriz/apps/vaktija/templates/vaktija.html b/idriz/apps/vaktija/templates/vaktija.html new file mode 100644 index 0000000..47deafd --- /dev/null +++ b/idriz/apps/vaktija/templates/vaktija.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Namaz — Idriz Admin{% endblock %} +{% block extra_css %} + .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; + padding: 18px 20px; max-width: 460px; } + .panel h2 { margin: 0 0 4px; font-size: 15px; } + .panel p.hint { color: var(--muted); font-size: 12.5px; margin: 0 0 14px; } + select { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid var(--line); + border-radius: 8px; background: #fff; color: var(--ink); } + select:focus { outline: none; border-color: var(--accent); } + button { margin-top: 14px; padding: 10px 20px; font-size: 14px; font-weight: 600; color: #fff; + background: var(--accent); border: none; border-radius: 8px; cursor: pointer; } + button:hover { filter: brightness(1.05); } +{% endblock %} +{% block content %} +

Namaz (vaktija)

+

Lokacija za koju Idriz kazuje vremena namaza.

+
+

Lokacija

+

Vremena se dohvataju sa api.vaktija.ba za odabrani grad.

+
+ + +
+
+{% endblock %}