Nova aplikacija "Kalendar" (kalendar)
Lični kalendar događaja/podsjetnika (tačka u vremenu: datum + opciono vrijeme,
bez trajanja). Alati (asistent):
- kalendar_dodaj: zabilježi događaj; model dodaje prikladan emoji (samo za štampu,
nikad se ne izgovara); pita za vrijeme kad prirodno postoji
- kalendar_obrisi / kalendar_pomjeri: po nazivu (uparivanje tolerantno na padeže),
traži pojašnjenje kad ima više istih
- kalendar_pretraga: naredni ("kad mi je zubar?") ili prošli ("kad sam zadnji put
posjetio nanu?")
- kalendar_pregled: dan / sedmica / mjesec
- kalendar_stampaj (background): A4 mjesečni grid (pejzaž) ili dnevna lista (uspravno)
Štampa (WeasyPrint): lagan mjesec staje na jednu stranu; pretrpan se čisto lomi po
sedmicama (zaglavlje Pon–Ned se ponovi). Emoji: bundlani MONOHROMATSKI vektorski
Noto Emoji + VS15 tekst-prezentacija (čist crni obris za laser); zastave i ZWJ/
porodični emoji se izostave da ne bi bilo color-fallbacka.
Admin: tab "Kalendar" (📅) za ručno dodavanje/izmjenu/brisanje događaja.
Podaci: ~/.local/share/idriz/kalendar.json (env IDRIZ_KALENDAR_PATH).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
@@ -19,13 +19,14 @@ from flask import (
|
|||||||
|
|
||||||
# Admin stranice mini-app-ova (lagani moduli iz glavnog paketa, bez openai zavisnosti).
|
# Admin stranice mini-app-ova (lagani moduli iz glavnog paketa, bez openai zavisnosti).
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
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.obroci import admin as _obroci_admin # noqa: E402
|
||||||
from idriz.apps.raspored import admin as _raspored_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.superheroji import admin as _superheroji_admin # noqa: E402
|
||||||
from idriz.apps.vaktija import admin as _vaktija_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).
|
# 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 -----------------------------------------------------------
|
# --- Konfiguracija -----------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ from .base import AppContext, AppSpec, AppTool # noqa: F401
|
|||||||
|
|
||||||
def load_apps() -> list[AppSpec]:
|
def load_apps() -> list[AppSpec]:
|
||||||
from .bojanka import manifest as bojanka
|
from .bojanka import manifest as bojanka
|
||||||
|
from .kalendar import manifest as kalendar
|
||||||
from .obroci import manifest as obroci
|
from .obroci import manifest as obroci
|
||||||
from .raspored import manifest as raspored
|
from .raspored import manifest as raspored
|
||||||
from .sazetak import manifest as sazetak
|
from .sazetak import manifest as sazetak
|
||||||
from .superheroji import manifest as superheroji
|
from .superheroji import manifest as superheroji
|
||||||
from .vaktija import manifest as vaktija
|
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]
|
||||||
|
|||||||
0
idriz/apps/kalendar/__init__.py
Normal file
0
idriz/apps/kalendar/__init__.py
Normal file
53
idriz/apps/kalendar/admin.py
Normal file
53
idriz/apps/kalendar/admin.py
Normal file
@@ -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")
|
||||||
BIN
idriz/apps/kalendar/fonts/NotoEmoji-Regular.ttf
Normal file
BIN
idriz/apps/kalendar/fonts/NotoEmoji-Regular.ttf
Normal file
Binary file not shown.
424
idriz/apps/kalendar/manifest.py
Normal file
424
idriz/apps/kalendar/manifest.py
Normal file
@@ -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"),
|
||||||
|
],
|
||||||
|
)
|
||||||
185
idriz/apps/kalendar/render.py
Normal file
185
idriz/apps/kalendar/render.py
Normal file
@@ -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"<span{c} style=\"font-family: '{EMOJI_FONT}', 'DejaVu Sans'\">"
|
||||||
|
f"{html.escape(e)}{_VS15}</span> ")
|
||||||
|
|
||||||
|
|
||||||
|
def _ev_line(e: dict) -> str:
|
||||||
|
vr = (e.get("vrijeme") + " ") if e.get("vrijeme") else ""
|
||||||
|
return f"{_emoji_span(e.get('emoji',''))}<b>{html.escape(vr)}</b>{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"<th class='{'we' if i >= 5 else ''}'>{store.DAN_KRATKO[i]}</th>" for i in range(7))
|
||||||
|
rows = ""
|
||||||
|
for week in weeks:
|
||||||
|
cells = ""
|
||||||
|
for i, day in enumerate(week):
|
||||||
|
if day == 0:
|
||||||
|
cells += "<td class='off'></td>"
|
||||||
|
continue
|
||||||
|
cls = ["we"] if i >= 5 else []
|
||||||
|
if year == today.year and month == today.month and day == today.day:
|
||||||
|
cls.append("today")
|
||||||
|
evs = "".join(f"<div>{_ev_line(e)}</div>" for e in by_day.get(day, []))
|
||||||
|
cells += (f"<td class='{' '.join(cls)}'>"
|
||||||
|
f"<span class='dnum'>{day}</span>"
|
||||||
|
f"<div class='evs'>{evs}</div></td>")
|
||||||
|
rows += f"<tr>{cells}</tr>"
|
||||||
|
|
||||||
|
naslov = f"{store.MJESEC_NOM[month]} {year}."
|
||||||
|
table = f"<table><thead><tr>{header}</tr></thead><tbody>{rows}</tbody></table>"
|
||||||
|
|
||||||
|
# Visina reda se bira tako da lagan mjesec (do 6 sedmica) stane na jednu stranu;
|
||||||
|
# kad je mjesec pretrpan, tabela se čisto prelomi po sedmicama (zaglavlje se ponovi).
|
||||||
|
rowh = 27 if len(weeks) <= 5 else 24
|
||||||
|
css = _MONTH_CSS.format(naslov=html.escape(naslov), evsize=9, rowh=rowh)
|
||||||
|
doc = HTML(string=f"<html><head><meta charset='utf-8'><style>{FONTFACE}{css}</style></head>"
|
||||||
|
f"<body><h1>{html.escape(naslov)}</h1>{table}</body></html>"
|
||||||
|
).render(font_config=_FONT_CONFIG)
|
||||||
|
doc.write_pdf(out)
|
||||||
|
log.info("mjesečni kalendar %s: %d sedmica, %d strana", out.name, len(weeks), len(doc.pages))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- dnevna lista ----------
|
||||||
|
|
||||||
|
_DAY_CSS = """
|
||||||
|
@page {{ size: A4; margin: 20mm; }}
|
||||||
|
body {{ font-family: "DejaVu Sans", "Noto Sans", sans-serif; color: #111; }}
|
||||||
|
h1 {{ font-size: 26pt; margin: 0 0 2pt 0; }}
|
||||||
|
.sub {{ font-size: 13pt; color: #666; margin: 0 0 16pt 0; }}
|
||||||
|
ul {{ list-style: none; padding: 0; margin: 0; }}
|
||||||
|
li {{ font-size: {size}pt; padding: 8pt 10pt; margin-bottom: 8pt;
|
||||||
|
border: 1.5pt solid #ccc; border-left: 5pt solid #c62828; border-radius: 6pt; }}
|
||||||
|
.vr {{ font-weight: bold; margin-right: 8pt; }}
|
||||||
|
.em {{ margin-right: 6pt; }}
|
||||||
|
.prazno {{ font-size: 15pt; color: #888; font-style: italic; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_day(date: dt.date, out: Path) -> Path:
|
||||||
|
events = store.events_on(date)
|
||||||
|
naslov = f"{date.day}. {store.MJESEC_NOM[date.month]} {date.year}."
|
||||||
|
sub = store.DAN_NOM[date.weekday()].capitalize()
|
||||||
|
|
||||||
|
if events:
|
||||||
|
items = ""
|
||||||
|
for e in events:
|
||||||
|
em = _emoji_span(e.get("emoji", ""), cls="em")
|
||||||
|
vr = f"<span class='vr'>{html.escape(e['vrijeme'])}</span>" if e.get("vrijeme") else ""
|
||||||
|
items += f"<li>{em}{vr}{html.escape(e['naziv'])}</li>"
|
||||||
|
body = f"<ul>{items}</ul>"
|
||||||
|
else:
|
||||||
|
body = "<p class='prazno'>Nema događaja ovog dana.</p>"
|
||||||
|
|
||||||
|
for size in (18, 16, 14, 12, 11):
|
||||||
|
css = _DAY_CSS.format(size=size)
|
||||||
|
doc = HTML(string=f"<html><head><meta charset='utf-8'><style>{FONTFACE}{css}</style></head>"
|
||||||
|
f"<body><h1>{html.escape(naslov)}</h1>"
|
||||||
|
f"<div class='sub'>{html.escape(sub)}</div>{body}</body></html>"
|
||||||
|
).render(font_config=_FONT_CONFIG)
|
||||||
|
if len(doc.pages) <= 1:
|
||||||
|
doc.write_pdf(out)
|
||||||
|
return out
|
||||||
|
doc.write_pdf(out)
|
||||||
|
return out
|
||||||
200
idriz/apps/kalendar/store.py
Normal file
200
idriz/apps/kalendar/store.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""Skladište za app 'Kalendar': lični događaji (podsjetnici).
|
||||||
|
|
||||||
|
Svaki događaj je tačka u vremenu (datum + opciono vrijeme), bez trajanja:
|
||||||
|
{id, datum (ISO 'YYYY-MM-DD'), vrijeme ('HH:MM' ili ''), naziv, emoji}.
|
||||||
|
Bez openai/requests (uvozi ga i admin).
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
KALENDAR_PATH = Path(os.environ.get(
|
||||||
|
"IDRIZ_KALENDAR_PATH", str(Path.home() / ".local/share/idriz/kalendar.json")))
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
# bosanski nazivi mjeseci (nominativ za naslove, genitiv za rečenice)
|
||||||
|
MJESEC_NOM = ["", "Januar", "Februar", "Mart", "April", "Maj", "Juni", "Juli",
|
||||||
|
"August", "Septembar", "Oktobar", "Novembar", "Decembar"]
|
||||||
|
MJESEC_GEN = ["", "januara", "februara", "marta", "aprila", "maja", "juna", "jula",
|
||||||
|
"augusta", "septembra", "oktobra", "novembra", "decembra"]
|
||||||
|
DAN_NOM = ["ponedjeljak", "utorak", "srijeda", "četvrtak", "petak", "subota", "nedjelja"]
|
||||||
|
DAN_AKUZATIV = ["ponedjeljak", "utorak", "srijedu", "četvrtak", "petak", "subotu", "nedjelju"]
|
||||||
|
DAN_KRATKO = ["Pon", "Uto", "Sri", "Čet", "Pet", "Sub", "Ned"]
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s: str) -> str:
|
||||||
|
s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
|
||||||
|
return re.sub(r"[^a-z0-9]+", "", s.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(s: str) -> list[str]:
|
||||||
|
s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
|
||||||
|
return [t for t in re.split(r"[^a-z0-9]+", s.lower()) if t]
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix(a: str, b: str) -> int:
|
||||||
|
n = 0
|
||||||
|
for x, y in zip(a, b):
|
||||||
|
if x != y:
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def name_match(query: str, naziv: str) -> bool:
|
||||||
|
"""Tolerantno na padeže/dijakritiku: cijeli upit kao podniz, ili preklapanje
|
||||||
|
riječi po prefiksu (≥4 znaka ili cijela kraća riječ) — hvata 'posjeta/posjetio'."""
|
||||||
|
qn = norm(query)
|
||||||
|
if not qn:
|
||||||
|
return True
|
||||||
|
if qn in norm(naziv):
|
||||||
|
return True
|
||||||
|
ntoks = _tokens(naziv)
|
||||||
|
for q in _tokens(query):
|
||||||
|
if len(q) < 3:
|
||||||
|
continue
|
||||||
|
for nt in ntoks:
|
||||||
|
if _prefix(q, nt) >= min(4, len(q), len(nt)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- učitavanje/snimanje ----------
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
try:
|
||||||
|
data = json.loads(KALENDAR_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
data.setdefault("dogadjaji", [])
|
||||||
|
data.setdefault("next_id", 1)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _save(data: dict) -> None:
|
||||||
|
with _lock:
|
||||||
|
KALENDAR_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(KALENDAR_PATH.parent), suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, KALENDAR_PATH)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_time(t: str) -> str:
|
||||||
|
"""Normalizuj vrijeme na 'HH:MM' ili '' (bez vremena / cijeli dan)."""
|
||||||
|
t = (t or "").strip()
|
||||||
|
if not t:
|
||||||
|
return ""
|
||||||
|
m = re.match(r"^(\d{1,2})[:.h](\d{2})$", t)
|
||||||
|
if m:
|
||||||
|
h, mm = int(m.group(1)), int(m.group(2))
|
||||||
|
else:
|
||||||
|
m = re.match(r"^(\d{1,2})$", t)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
h, mm = int(m.group(1)), 0
|
||||||
|
if 0 <= h <= 23 and 0 <= mm <= 59:
|
||||||
|
return f"{h:02d}:{mm:02d}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- CRUD ----------
|
||||||
|
|
||||||
|
def add_event(naziv: str, datum: dt.date, vrijeme: str = "", emoji: str = "") -> dict:
|
||||||
|
naziv = (naziv or "").strip()
|
||||||
|
data = _load()
|
||||||
|
ev = {
|
||||||
|
"id": data["next_id"],
|
||||||
|
"datum": datum.isoformat(),
|
||||||
|
"vrijeme": clean_time(vrijeme),
|
||||||
|
"naziv": naziv,
|
||||||
|
"emoji": (emoji or "").strip(),
|
||||||
|
}
|
||||||
|
data["dogadjaji"].append(ev)
|
||||||
|
data["next_id"] += 1
|
||||||
|
_save(data)
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
def all_events() -> list[dict]:
|
||||||
|
"""Svi događaji, hronološki (datum pa vrijeme; bez vremena = na vrhu dana)."""
|
||||||
|
evs = _load()["dogadjaji"]
|
||||||
|
return sorted(evs, key=lambda e: (e["datum"], e.get("vrijeme") or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def get_event(eid: int) -> dict | None:
|
||||||
|
for e in _load()["dogadjaji"]:
|
||||||
|
if e["id"] == eid:
|
||||||
|
return e
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event(eid: int) -> bool:
|
||||||
|
data = _load()
|
||||||
|
n = len(data["dogadjaji"])
|
||||||
|
data["dogadjaji"] = [e for e in data["dogadjaji"] if e["id"] != eid]
|
||||||
|
if len(data["dogadjaji"]) != n:
|
||||||
|
_save(data)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def update_event(eid: int, datum: dt.date | None = None, vrijeme: str | None = None,
|
||||||
|
naziv: str | None = None, emoji: str | None = None) -> bool:
|
||||||
|
data = _load()
|
||||||
|
for e in data["dogadjaji"]:
|
||||||
|
if e["id"] == eid:
|
||||||
|
if datum is not None:
|
||||||
|
e["datum"] = datum.isoformat()
|
||||||
|
if vrijeme is not None:
|
||||||
|
e["vrijeme"] = clean_time(vrijeme)
|
||||||
|
if naziv is not None:
|
||||||
|
e["naziv"] = naziv.strip()
|
||||||
|
if emoji is not None:
|
||||||
|
e["emoji"] = emoji.strip()
|
||||||
|
_save(data)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- pretraga / upiti ----------
|
||||||
|
|
||||||
|
def find_by_name(query: str, when: str = "svi") -> list[dict]:
|
||||||
|
"""Nađi događaje čiji naziv sadrži upit (tolerantno na padeže/dijakritiku).
|
||||||
|
|
||||||
|
when: 'svi' | 'naredni' (od danas nadalje) | 'prosli' (do danas).
|
||||||
|
Za 'naredni' sortira rastuće (najbliži prvi), za 'prosli' opadajuće (najskoriji prvi).
|
||||||
|
"""
|
||||||
|
today = dt.date.today().isoformat()
|
||||||
|
out = [e for e in _load()["dogadjaji"] if name_match(query, e["naziv"])]
|
||||||
|
if when == "naredni":
|
||||||
|
out = [e for e in out if e["datum"] >= today]
|
||||||
|
out.sort(key=lambda e: (e["datum"], e.get("vrijeme") or ""))
|
||||||
|
elif when == "prosli":
|
||||||
|
out = [e for e in out if e["datum"] < today]
|
||||||
|
out.sort(key=lambda e: (e["datum"], e.get("vrijeme") or ""), reverse=True)
|
||||||
|
else:
|
||||||
|
out.sort(key=lambda e: (e["datum"], e.get("vrijeme") or ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def events_between(start: dt.date, end: dt.date) -> list[dict]:
|
||||||
|
"""Događaji u rasponu [start, end] (uključivo), hronološki."""
|
||||||
|
s, e = start.isoformat(), end.isoformat()
|
||||||
|
out = [ev for ev in _load()["dogadjaji"] if s <= ev["datum"] <= e]
|
||||||
|
out.sort(key=lambda ev: (ev["datum"], ev.get("vrijeme") or ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def events_on(date: dt.date) -> list[dict]:
|
||||||
|
return events_between(date, date)
|
||||||
49
idriz/apps/kalendar/templates/kalendar.html
Normal file
49
idriz/apps/kalendar/templates/kalendar.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Kalendar — Idriz Admin{% endblock %}
|
||||||
|
{% block extra_css %}
|
||||||
|
table { border-collapse: collapse; width: 100%; background: var(--panel);
|
||||||
|
border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
||||||
|
th, td { padding: 8px 10px; text-align: left; font-size: 14px; border-bottom: 1px solid var(--line); }
|
||||||
|
th { background: #f7f8fa; color: var(--muted); font-weight: 600; font-size: 12.5px; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
input[type=text], input[type=date], input[type=time] {
|
||||||
|
padding: 7px 8px; font-size: 14px; border: 1px solid var(--line);
|
||||||
|
border-radius: 7px; background: #fff; color: var(--ink); width: 100%; }
|
||||||
|
input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
input.emoji { text-align: center; width: 56px; }
|
||||||
|
td.del { text-align: center; width: 42px; }
|
||||||
|
button { margin-top: 16px; padding: 10px 22px; font-size: 14px; font-weight: 600; color: #fff;
|
||||||
|
background: var(--accent); border: none; border-radius: 8px; cursor: pointer; }
|
||||||
|
button:hover { filter: brightness(1.05); }
|
||||||
|
.hint { color: var(--muted); font-size: 12.5px; margin: 14px 0 4px; }
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Kalendar</h1>
|
||||||
|
<p class="sub">{{ broj }} događaj(a). Prazni redovi na dnu služe za dodavanje novih.</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="nrows" value="{{ nrows }}">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width:130px">Datum</th>
|
||||||
|
<th style="width:90px">Vrijeme</th>
|
||||||
|
<th>Naziv</th>
|
||||||
|
<th style="width:64px">Emoji</th>
|
||||||
|
<th class="del">Obriši</th>
|
||||||
|
</tr>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if r.id %}<input type="hidden" name="r{{ loop.index0 }}_id" value="{{ r.id }}">{% endif %}
|
||||||
|
<input type="date" name="r{{ loop.index0 }}_datum" value="{{ r.datum or '' }}">
|
||||||
|
</td>
|
||||||
|
<td><input type="time" name="r{{ loop.index0 }}_vrijeme" value="{{ r.vrijeme or '' }}"></td>
|
||||||
|
<td><input type="text" name="r{{ loop.index0 }}_naziv" value="{{ r.naziv or '' }}" placeholder="npr. Zubar"></td>
|
||||||
|
<td><input type="text" class="emoji" name="r{{ loop.index0 }}_emoji" value="{{ r.emoji or '' }}"></td>
|
||||||
|
<td class="del">{% if r.id %}<input type="checkbox" name="r{{ loop.index0 }}_del">{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<p class="hint">Vrijeme možeš ostaviti prazno (cjelodnevni događaj). Emoji je samo za ljepšu štampu — Idriz ga ne izgovara.</p>
|
||||||
|
<button type="submit">Sačuvaj</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user