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).
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
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