Nova aplikacija "Namaz" (vaktija)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
@@ -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 -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
0
idriz/apps/vaktija/__init__.py
Normal file
0
idriz/apps/vaktija/__init__.py
Normal file
25
idriz/apps/vaktija/admin.py
Normal file
25
idriz/apps/vaktija/admin.py
Normal file
@@ -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",
|
||||
)
|
||||
67
idriz/apps/vaktija/client.py
Normal file
67
idriz/apps/vaktija/client.py
Normal file
@@ -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
|
||||
1
idriz/apps/vaktija/lokacije.json
Normal file
1
idriz/apps/vaktija/lokacije.json
Normal file
@@ -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"]
|
||||
130
idriz/apps/vaktija/manifest.py
Normal file
130
idriz/apps/vaktija/manifest.py
Normal file
@@ -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")],
|
||||
)
|
||||
61
idriz/apps/vaktija/store.py
Normal file
61
idriz/apps/vaktija/store.py
Normal file
@@ -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
|
||||
30
idriz/apps/vaktija/templates/vaktija.html
Normal file
30
idriz/apps/vaktija/templates/vaktija.html
Normal file
@@ -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 %}
|
||||
<h1>Namaz (vaktija)</h1>
|
||||
<p class="sub">Lokacija za koju Idriz kazuje vremena namaza.</p>
|
||||
<div class="panel">
|
||||
<h2>Lokacija</h2>
|
||||
<p class="hint">Vremena se dohvataju sa api.vaktija.ba za odabrani grad.</p>
|
||||
<form method="post">
|
||||
<select name="lokacija">
|
||||
{% for l in lokacije %}
|
||||
<option value="{{ l.id }}" {{ 'selected' if l.id == odabrana else '' }}>{{ l.naziv }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Sačuvaj</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user