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:
idriz
2026-09-13 09:26:26 +02:00
parent cb4e021111
commit a755a83e7e
9 changed files with 915 additions and 2 deletions

View File

@@ -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 -----------------------------------------------------------

View File

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

View File

View 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")

Binary file not shown.

View 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"),
],
)

View 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

View 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)

View 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 %}