From f07fdad919461eea9c2e619f7156d966e10a4bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 15:02:47 +0200 Subject: [PATCH] Crisis V7 MEGA: sufinanciranje_sport + panel + CRM auth DB: - pgz_sport.sufinanciranje_sport.je_klub flag (RSS programi/totals false) - pgz_sport.sufinanciranje_sport.klub_id matched Endpoints: - /v2/potpore/by-year: samo_klubovi=True default + davatelj filter Frontend: - sport2.html PANEL FORCE HIDE CSS (right:-100vw default) - crm_v2.html: redirect to /login only on actual 401, not on page load --- pgz_sport_api.py | 10 + pgz_sport_v2_router.py | 26 +- scrapers/harvesters/civic_data_pgz.py | 70 +++++ scrapers/harvesters/events_pgz.py | 70 +++++ scrapers/harvesters/gorski_kotar_pgz.py | 69 +++++ scrapers/harvesters/media_deep_pgz.py | 65 +++++ scrapers/harvesters/naselja_pgz.py | 83 ++++++ scrapers/harvesters/nekretnine_pgz.py | 63 +++++ scrapers/harvesters/otoci_deep_pgz.py | 68 +++++ scrapers/harvesters/povijest_pgz.py | 68 +++++ scrapers/harvesters/propisi_pgz.py | 66 +++++ scrapers/harvesters/sport_infra_pgz.py | 68 +++++ scrapers/harvesters/sport_klubovi_pgz.py | 72 ++++++ scrapers/harvesters/vjera_pgz.py | 67 +++++ scrapers/harvesters/zdravstvo_pgz.py | 68 +++++ static/crm_v2.html | 53 +++- static/shared/sidebar.js | 2 +- static/sport2.html | 312 +++++++++++++++++++---- 18 files changed, 1235 insertions(+), 65 deletions(-) create mode 100644 scrapers/harvesters/civic_data_pgz.py create mode 100644 scrapers/harvesters/events_pgz.py create mode 100644 scrapers/harvesters/gorski_kotar_pgz.py create mode 100644 scrapers/harvesters/media_deep_pgz.py create mode 100644 scrapers/harvesters/naselja_pgz.py create mode 100644 scrapers/harvesters/nekretnine_pgz.py create mode 100644 scrapers/harvesters/otoci_deep_pgz.py create mode 100644 scrapers/harvesters/povijest_pgz.py create mode 100644 scrapers/harvesters/propisi_pgz.py create mode 100644 scrapers/harvesters/sport_infra_pgz.py create mode 100644 scrapers/harvesters/sport_klubovi_pgz.py create mode 100644 scrapers/harvesters/vjera_pgz.py create mode 100644 scrapers/harvesters/zdravstvo_pgz.py diff --git a/pgz_sport_api.py b/pgz_sport_api.py index 39c765b..b4f7b7f 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -674,10 +674,18 @@ def get_savez(savez_id: int, authorization: Optional[str] = Header(None)): return {**savez, "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije} # ==================== KLUBOVI ==================== +# ───────────────────────────────────────────────────────────────────── +# Endpoint: GET /api/klubovi +# Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one) +# Date: 2026-05-05 (BUG-E filter sprint) +# Note: `samo_hns_roster` added — keeps priority-sort behaviour but +# lets UI filter to klubs that have at least 1 HNS roster row. +# ───────────────────────────────────────────────────────────────────── @app.get("/api/klubovi") def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None, kategorija: Optional[str] = None, godisnjak: Optional[bool] = None, financiran: Optional[bool] = None, + samo_hns_roster: Optional[bool] = None, sort: str = "naziv", order: str = "asc"): where = ["v.aktivan"] params = [] @@ -703,6 +711,8 @@ def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = where.append("(k.godisnjak_godine IS NULL OR array_length(k.godisnjak_godine,1) IS NULL)") if kategorija and kategorija.strip().lower() == "priority": where.append("(COALESCE(k.pgz_sufinanciran,false) OR (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0))") + if samo_hns_roster: + where.append("EXISTS (SELECT 1 FROM pgz_sport.hns_klub_roster r WHERE r.klub_id = k.id)") sort_col = {"naziv": "v.klub", "savez": "v.savez", "broj_clanova": "v.broj_clanova", "razina": "v.razina", "region": "v.region", "grad": "v.grad", "sport": "v.sport"}.get(sort, "v.klub") order_sql = "DESC" if order.lower() == "desc" else "ASC" diff --git a/pgz_sport_v2_router.py b/pgz_sport_v2_router.py index d63089f..2768be4 100644 --- a/pgz_sport_v2_router.py +++ b/pgz_sport_v2_router.py @@ -4960,19 +4960,31 @@ def proracun_sport(godina: int = None): # POTPORE — by year filter # ═══════════════════════════════════════════════════════ @router.get("/potpore/by-year") -def potpore_by_year(godina: int = None, q: str = ""): - """Sufinanciranje za specifičnu godinu.""" +def potpore_by_year(godina: int = None, q: str = "", samo_klubovi: bool = True, davatelj: str = None): + """Sufinanciranje za specifičnu godinu — samo_klubovi=True izbacuje programe/totals/services.""" import datetime yr = godina or datetime.date.today().year like = f"%{q}%" if q else "%" - rows = db_query(""" - SELECT korisnik, sport, iznos_eur, vrsta, napomena, izvor, source_url, godina, - (SELECT k.id FROM pgz_sport.klubovi k WHERE LOWER(k.naziv) LIKE LOWER('%%'||LEFT(korisnik,20)||'%%') AND k.aktivan=true LIMIT 1) as klub_id + + where = ["godina = %s", "LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s)"] + params = [yr, like] + + if samo_klubovi: + where.append("(je_klub IS NULL OR je_klub = true)") + + if davatelj == 'rijeka': + where.append("izvor ILIKE '%%rijeka.hr%%'") + elif davatelj == 'pgz': + where.append("izvor ILIKE '%%sport-pgz%%'") + + sql = f""" + SELECT id, korisnik, sport, iznos_eur, vrsta, napomena, izvor, source_url, godina, klub_id, je_klub FROM pgz_sport.sufinanciranje_sport - WHERE godina = %s AND LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s) + WHERE {' AND '.join(where)} ORDER BY iznos_eur DESC NULLS LAST LIMIT 500 - """, (yr, like)) + """ + rows = db_query(sql, params) total = sum(float(r.get('iznos_eur') or 0) for r in rows) return {"godina": yr, "count": len(rows), "total": total, "results": rows} diff --git a/scrapers/harvesters/civic_data_pgz.py b/scrapers/harvesters/civic_data_pgz.py new file mode 100644 index 0000000..bedaec2 --- /dev/null +++ b/scrapers/harvesters/civic_data_pgz.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""data.gov.hr — Open Data PGZ.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import upsert_facts, DSN, UA +import urllib.request +import psycopg2 + +API = "https://data.gov.hr/api/3/action" + + +def search(query, rows=50): + url = f"{API}/package_search?q={urllib.parse.quote(query)}&rows={rows}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=30) as r: + d = json.loads(r.read()) + return d.get("result", {}).get("results", []) + except Exception as e: + print(f"search err: {e}") + return [] + +import urllib.parse + + +def main(): + conn = psycopg2.connect(DSN); conn.autocommit = True + + queries = [ + "Primorsko-goranska", "Rijeka", "Opatija", "Crikvenica", "Krk", + "Cres", "Lošinj", "Rab", "Delnice", "Bakar", "Kvarner", + ] + + total_inserted = 0 + seen = set() + + for q in queries: + results = search(q, rows=50) + ff = [] + for pkg in results: + pkg_id = pkg.get("id", "") + if pkg_id in seen: continue + seen.add(pkg_id) + + title = pkg.get("title", "") + notes = pkg.get("notes", "")[:600] + org = pkg.get("organization", {}).get("title", "") + tags = ", ".join([t.get("name", "") for t in pkg.get("tags", [])]) + + fact = f"[OpenData] {title} | Org: {org} | {notes} | Tags: {tags}"[:1200] + if len(fact) > 50: + ff.append({ + "fact": fact, + "url": f"https://data.gov.hr/dataset/{pkg.get('name', '')}", + "title": title, + }) + + n = upsert_facts(conn, ff, source_name="data_gov_hr_pgz", + category="opendata_pgz", confidence=0.85) + total_inserted += n + print(f" query='{q}' -> {len(results)} results, {n} new facts") + time.sleep(1) + + conn.close() + print(f"=== TOTAL: {total_inserted} ===") + print(json.dumps({"queries": len(queries), "total_facts": total_inserted})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/events_pgz.py b/scrapers/harvesters/events_pgz.py new file mode 100644 index 0000000..d98298b --- /dev/null +++ b/scrapers/harvesters/events_pgz.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Public events i festivali PGZ.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +EVENTS = { + "rijeka_karneval": ["https://www.rijecki-karneval.hr/", "https://rijekakarneval.hr/"], + "ljeto_kvarner": ["https://www.kvarner-ljeto.hr/"], + "rijeka_film_fest": ["https://www.riff.hr/"], + "vinski_festival_op": ["https://opatijawine.hr/"], + "festival_culture": ["https://www.festival-of-cultures.hr/"], + "muzicki_kvarner": ["https://www.kvarnermusic.hr/"], + "porto_etno": ["https://www.portoetno.eu/"], + "ri_rock": ["https://rockkonferencija.com/"], + "fjeshta_lovran": ["https://www.fjeshta.hr/"], + "njanje_zvoncari": ["https://halubajskizvoncari.hr/"], + "skoljka_festival": ["https://www.kostrena.hr/"], + "sumski_film": ["https://www.lifftrijeka.hr/"], + "zicfest": ["https://www.zicfest.hr/"], +} + + +def crawl(name, urls, max_pages=12): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="events_pgz", confidence=0.85) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in EVENTS.items(): + try: + r = crawl(name, urls, max_pages=10) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"events_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/gorski_kotar_pgz.py b/scrapers/harvesters/gorski_kotar_pgz.py new file mode 100644 index 0000000..9a63e76 --- /dev/null +++ b/scrapers/harvesters/gorski_kotar_pgz.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Gorski kotar deep — Risnjak, Delnice, Cabar, Lokve.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +GORSKI = { + "tz_gorski_kotar": ["https://www.gorskikotar.hr/", "https://www.tz-gorskikotar.hr/"], + "np_risnjak": ["https://www.np-risnjak.hr/"], + "delnice_info": ["https://delnice.com/"], + "cabar_info": ["https://www.cabar.hr/"], + "lokvarsko_jezero": ["https://www.lokve.hr/"], + "fuzine_info": ["https://www.fuzine.hr/"], + "ravnagora_blog": ["https://www.ravnagora.hr/"], + "skrad_info": ["https://www.skrad.hr/"], + "vrbovsko_info": ["https://www.vrbovsko.hr/"], + "spilja_lokvarka": ["https://www.spilja-lokvarka.com/"], + "skijanje_platak": ["https://www.platak.hr/"], + "snowboard_klub": ["https://www.snowboardklub-rijeka.hr/"], +} + + +def crawl(name, urls, max_pages=15): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="gorski_kotar_pgz", confidence=0.85) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 40: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in GORSKI.items(): + try: + r = crawl(name, urls, max_pages=12) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"gorski_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/media_deep_pgz.py b/scrapers/harvesters/media_deep_pgz.py new file mode 100644 index 0000000..301dfae --- /dev/null +++ b/scrapers/harvesters/media_deep_pgz.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Media deep crawl — full pages of local portals.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +MEDIA = { + "novilist_rijeka": ["https://www.novilist.hr/rijeka/", "https://www.novilist.hr/regija/"], + "rijekadanas_full": ["https://rijekadanas.com/category/rijeka/", "https://rijekadanas.com/category/pgz/"], + "rijekain_full": ["https://rijekain.hr/category/rijeka/"], + "primorske_full": ["https://primorskenovice.hr/"], + "rkc_blog": ["https://www.rkcrijeka.hr/blog/"], + "rijeka2020_arhiva": ["https://rijeka2020.eu/category/news/"], + "kulturpunkt_ri": ["https://www.kulturpunkt.hr/tag/rijeka"], + "5portala_hr_pgz": ["https://www.5portala.hr/regije/primorsko-goranska/"], +} + + +def crawl(name, urls, max_pages=20): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 300: continue + ff = [] + if title and len(title) > 12: + ff.append({"fact": f"[{name}] {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 120: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=f"media_{name}", + category="media_pgz_deep", confidence=0.82) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 60: + queue.append(link) + time.sleep(0.6) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in MEDIA.items(): + try: + r = crawl(name, urls, max_pages=18) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"media_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/naselja_pgz.py b/scrapers/harvesters/naselja_pgz.py new file mode 100644 index 0000000..9784ec2 --- /dev/null +++ b/scrapers/harvesters/naselja_pgz.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Naselja PGZ — sela, zaseoci, otocna mjesta.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import chunk_text, upsert_facts, DSN, UA +from urllib.parse import urlencode, quote +import urllib.request +import psycopg2 + +API_HR = "https://hr.wikipedia.org/w/api.php" + + +def wiki_cat_members(cat, limit=200): + """Get pages in a Wikipedia category.""" + params = {"action":"query","list":"categorymembers","cmtitle":cat, + "cmlimit":str(limit),"format":"json"} + url = API_HR + "?" + urlencode(params) + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=20) as r: + d = json.loads(r.read()) + return [m["title"] for m in d.get("query", {}).get("categorymembers", [])] + except Exception: + return [] + + +def wiki_extract(title, timeout=15): + params = {"action":"query","prop":"extracts","explaintext":"1", + "redirects":"1","format":"json","titles":title} + url = API_HR + "?" + urlencode(params) + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=timeout) as r: + d = json.loads(r.read()) + for pid, p in d.get("query", {}).get("pages", {}).items(): + if pid == "-1": return None + return p.get("extract", "") + except Exception: + return None + + +CATEGORIES = [ + "Kategorija:Naselja_u_Primorsko-goranskoj_županiji", + "Kategorija:Naselja_u_Hrvatskoj_(otok_Krk)", + "Kategorija:Naselja_u_Hrvatskoj_(otok_Cres)", + "Kategorija:Naselja_u_Hrvatskoj_(otok_Lošinj)", + "Kategorija:Naselja_u_Hrvatskoj_(otok_Rab)", + "Kategorija:Gorski_kotar", +] + + +def main(): + conn = psycopg2.connect(DSN); conn.autocommit = True + total = 0; pages = 0 + + seen = set() + for cat in CATEGORIES: + members = wiki_cat_members(cat, limit=200) + print(f" {cat[:50]:50} {len(members):>3} members") + + for title in members: + if title in seen: continue + seen.add(title) + + text = wiki_extract(title) + if not text or len(text) < 200: continue + pages += 1 + + facts = [{"fact": c, "url": f"https://hr.wikipedia.org/wiki/{quote(title)}", + "title": title} + for c in chunk_text(text, 600) if len(c) > 100] + n = upsert_facts(conn, facts, source_name="wikipedia_pgz_naselja", + category="naselja_pgz", confidence=0.86) + total += n + time.sleep(0.3) + + conn.close() + print(f"=== TOTAL pages={pages} facts={total} ===") + print(json.dumps({"pages": pages, "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/nekretnine_pgz.py b/scrapers/harvesters/nekretnine_pgz.py new file mode 100644 index 0000000..b0dd18d --- /dev/null +++ b/scrapers/harvesters/nekretnine_pgz.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Real estate + housing PGZ.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +REAL = { + "rijeka_najam": ["https://www.rijeka.hr/javnu-najam/"], + "stanovanje_pgz": ["https://www.dom-rijeka.hr/"], + "katastar_pgz": ["https://geoportal.dgu.hr/"], + "uprava_imovine": ["https://www.rijeka.hr/imovinsko-pravna/"], + "rijeka_arhitekt": ["https://www.rijeka.hr/arhitektonska/"], + "drzavna_imovina": ["https://www.drzavnaimovina.hr/"], +} + + +def crawl(name, urls, max_pages=8): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="nekretnine_pgz", confidence=0.83) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 25: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in REAL.items(): + try: + r = crawl(name, urls, max_pages=8) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"real_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/otoci_deep_pgz.py b/scrapers/harvesters/otoci_deep_pgz.py new file mode 100644 index 0000000..1524a28 --- /dev/null +++ b/scrapers/harvesters/otoci_deep_pgz.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Otoci PGZ deep — Krk, Cres, Losinj, Rab portali.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +OTOCI = { + "krk_info": ["https://www.krk.com/"], + "krkonline": ["https://www.krkonline.com/"], + "krkinfo_news": ["https://www.krk-info.com/"], + "cres_info": ["https://www.cres.info/"], + "cres_lapis": ["https://lapis.cres.hr/"], + "losinj_info": ["https://www.losinj.info/"], + "losinj_centar": ["https://www.muzejmalilosinj.hr/"], + "rab_info": ["https://www.rab.com/"], + "rab_news": ["https://rabnews.com/"], + "susak_info": ["https://www.susakisland.com/"], + "ilovik_info": ["https://www.ilovik.eu/"], +} + + +def crawl(name, urls, max_pages=12): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="otoci_pgz", confidence=0.85) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in OTOCI.items(): + try: + r = crawl(name, urls, max_pages=12) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"otoci_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/povijest_pgz.py b/scrapers/harvesters/povijest_pgz.py new file mode 100644 index 0000000..b1bc207 --- /dev/null +++ b/scrapers/harvesters/povijest_pgz.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Povijesni izvori PGZ — Liburnija, arhivi.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +HISTORY = { + "drzavni_arhiv_ri": ["https://www.dari.hr/"], + "arhiv_pazin": ["https://www.dapa.hr/"], + "muzej_glagoljice": ["https://glagoljica.hr/"], + "glagoljaska_alea": ["https://www.aleja-glagoljasa.hr/"], + "frankopani": ["https://www.frankopani.eu/"], + "trsatske_legende": ["https://www.trsat-svetiste.com/povijest/"], + "rijeka_povijest": ["https://rijeka-history.eu/"], + "stare_rijeke": ["https://www.stararijeka.com/"], + "kvarner_arhiv": ["https://www.kvarnerheritage.eu/"], + "muzeji_pgz_arhiv": ["https://www.muzeji-pgz.hr/"], + "razno_pomorski": ["https://www.kpu.hr/"], +} + + +def crawl(name, urls, max_pages=12): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="povijest_pgz", confidence=0.86) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in HISTORY.items(): + try: + r = crawl(name, urls, max_pages=10) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"hist_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/propisi_pgz.py b/scrapers/harvesters/propisi_pgz.py new file mode 100644 index 0000000..fcadd54 --- /dev/null +++ b/scrapers/harvesters/propisi_pgz.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Lokalni propisi PGZ — sluzbene novine, statuti.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +PROPISI = { + "sluzbene_novine_pgz": ["https://www.sn.pgz.hr/"], + "sluzbene_glasnik_rijeka": ["https://www.sluzbene-novine.com/"], + "sn_opatija": ["https://www.opatija.hr/sluzbene-novine"], + "pgz_dokumenti": ["https://www.pgz.hr/dokumenti"], + "pgz_skupstina": ["https://www.pgz.hr/skupstina"], + "rijeka_grad_v_savjet":["https://www.rijeka.hr/gradska-uprava/"], + "pgz_javnatime": ["https://www.pgz.hr/javna-nabava/"], + "rijeka_javna_nabava": ["https://www.rijeka.hr/javna-nabava/"], + "narodne_novine_pgz": ["https://narodne-novine.nn.hr/"], +} + + +def crawl(name, urls, max_pages=15): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="propisi_pgz", confidence=0.88) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 60: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in PROPISI.items(): + try: + r = crawl(name, urls, max_pages=15) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"propisi_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/sport_infra_pgz.py b/scrapers/harvesters/sport_infra_pgz.py new file mode 100644 index 0000000..1b2128a --- /dev/null +++ b/scrapers/harvesters/sport_infra_pgz.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Sport infrastruktura PGZ — dvorane, baze, skole.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +INFRA = { + "dvorana_zamet": ["https://www.dvoranazamet.hr/"], + "stadion_kantrida": ["https://www.kantrida.hr/"], + "stadion_rujevica": ["https://www.nk-rijeka.hr/stadion-rujevica/"], + "ck_kantrida_pliv": ["https://kantridapool.hr/"], + "ri_sport_centar": ["https://www.ri-sport.hr/"], + "delta_jumbo": ["https://www.deltajumbo.hr/"], + "skolski_sport": ["https://www.hsss.hr/"], + "platak_skijanje": ["https://www.platak.hr/"], + "rec_velebit": ["https://www.velebit.hr/"], + "platak_ski_klub": ["https://www.skiclub-platak.hr/"], + "rijeka_marina": ["https://www.aci-marinas.com/"], +} + + +def crawl(name, urls, max_pages=15): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="sport_infra_pgz", confidence=0.85) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 40: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in INFRA.items(): + try: + r = crawl(name, urls, max_pages=12) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"infra_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/sport_klubovi_pgz.py b/scrapers/harvesters/sport_klubovi_pgz.py new file mode 100644 index 0000000..b11355c --- /dev/null +++ b/scrapers/harvesters/sport_klubovi_pgz.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Sport klubovi PGZ — direktno s web stranica.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +KLUBOVI = { + "hnk_rijeka": ["https://www.nk-rijeka.hr/"], + "kk_kvarner": ["https://www.kk-kvarner.hr/"], + "rk_zamet": ["https://www.rk-zamet.hr/"], + "vk_primorje": ["https://www.vkprimorje.hr/"], + "ok_rijeka": ["https://www.ok-rijeka.hr/"], + "haok_mladost": ["https://www.haok-mladost.hr/"], + "abc_rijeka": ["https://www.abc-rijeka.hr/"], + "rugby_rijeka": ["https://www.rugbyrijeka.hr/"], + "pliva_klub_primorje":["https://www.primorje-aquarius.hr/"], + "judo_kvarner": ["https://www.judokvarner.hr/"], + "kuglacki_savez_pgz": ["https://www.kuglacki-savez-pgz.hr/"], + "tenis_kvarner": ["https://www.tk-kvarner.hr/"], + "atletika_rijeka": ["https://www.akrijeka.hr/"], + "biciklisticki": ["https://www.bk-rijeka.hr/"], + "stoljecesporta": ["https://stoljecesporta.com/"], +} + + +def crawl(name, urls, max_pages=12): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="sport_klub_pgz", confidence=0.88) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in KLUBOVI.items(): + try: + r = crawl(name, urls, max_pages=10) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"klub_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/vjera_pgz.py b/scrapers/harvesters/vjera_pgz.py new file mode 100644 index 0000000..8313de6 --- /dev/null +++ b/scrapers/harvesters/vjera_pgz.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Crkve i vjerske institucije PGZ.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +CRKVA = { + "rijecka_nadbiskupija":["https://www.ri-nadbiskupija.hr/"], + "krcka_biskupija": ["https://www.biskupija-krk.hr/"], + "isusovci_rijeka": ["https://isusovci.hr/"], + "trsat_svetiste": ["https://trsat-svetiste.com/"], + "katedrala_rijeka": ["https://katedrala-rijeka.hr/"], + "samostan_kosljun": ["https://www.kosljun.hr/"], + "samostan_glavotok": ["https://www.glavotok.hr/"], + "katedrala_krk": ["https://www.biskupija-krk.hr/katedrala/"], + "crkva_opatija": ["https://www.zupa-opatija.hr/"], + "rijecka_eparhija": ["https://www.eparhija-zagrebackoljubljanska.com/"], +} + + +def crawl(name, urls, max_pages=10): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="vjera_pgz", confidence=0.84) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 25: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in CRKVA.items(): + try: + r = crawl(name, urls, max_pages=10) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"crkva_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/scrapers/harvesters/zdravstvo_pgz.py b/scrapers/harvesters/zdravstvo_pgz.py new file mode 100644 index 0000000..0959851 --- /dev/null +++ b/scrapers/harvesters/zdravstvo_pgz.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Zdravstvo + udruge PGZ.""" +import sys, json, time +sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters") +from _common import (fetch, extract_text, extract_title, chunk_text, + upsert_facts, find_internal_links, DSN) +from urllib.parse import urlparse +import psycopg2 + +ZDRAVSTVO = { + "kbc_rijeka": ["https://www.kbc-rijeka.hr/"], + "thalassotherapia": ["https://thalassotherapia-opatija.hr/"], + "klinika_lovran": ["https://www.tnz-lovran.hr/"], + "klinika_crikvenica": ["https://www.thalassotherapia-crikvenica.hr/"], + "dom_zdravlja_pgz": ["https://www.dom-zdravlja-pgz.hr/"], + "zavod_javno_zdravlje":["https://www.zzjzpgz.hr/"], + "crveni_kriz_pgz": ["https://www.crveni-kriz-rijeka.hr/"], + "hzzo_rijeka": ["https://hzzo.hr/"], + "savjetovaliste_ri": ["https://www.zzjzpgz.hr/savjetovaliste/"], + "deinstitucionalizacija":["https://www.cczg-rijeka.hr/"], + "centar_socijalne": ["https://www.czss.rijeka.hr/"], +} + + +def crawl(name, urls, max_pages=12): + conn = psycopg2.connect(DSN); conn.autocommit = True + visited = set(); queue = list(urls); facts = 0 + while queue and len(visited) < max_pages: + url = queue.pop(0) + if url in visited: continue + visited.add(url) + html, status = fetch(url, timeout=15) + if not html or status != 200: continue + title = extract_title(html); text = extract_text(html) + if not text or len(text) < 200: continue + ff = [] + if title and len(title) > 8: + ff.append({"fact": f"{name} - {title}", "url": url, "title": title}) + for c in chunk_text(text, 800): + if len(c) > 100: + ff.append({"fact": c, "url": url, "title": title}) + facts += upsert_facts(conn, ff, source_name=name, + category="zdravstvo_pgz", confidence=0.86) + base = urlparse(url).hostname + for link in find_internal_links(html, url): + if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 40: + queue.append(link) + time.sleep(0.5) + conn.close() + return {"name": name, "visited": len(visited), "facts": facts} + + +def main(): + results = [] + for name, urls in ZDRAVSTVO.items(): + try: + r = crawl(name, urls, max_pages=10) + print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f") + results.append(r) + except Exception as e: + print(f" {name:25} FAIL: {str(e)[:60]}") + total = sum(r.get("facts", 0) for r in results) + print(f"=== TOTAL: {total} ===") + print(json.dumps({"zdr_count": len(results), "total_facts": total})) + + +if __name__ == "__main__": + main() diff --git a/static/crm_v2.html b/static/crm_v2.html index 2181861..bb0b704 100644 --- a/static/crm_v2.html +++ b/static/crm_v2.html @@ -487,7 +487,18 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
@@ -406,6 +423,83 @@ const _state = {section:'dashboard', viewSavezi:'card', viewKlubovi:'card', view const _sort = {savezi:null, klubovi:null, sportasi:null, objekti:null, manifestacije:null, financije:null}; let _proracunChart=null, _financijeChart=null; +// ════════════════════════════════════════════════════════════════════ +// BUG-E (2026-05-05) — explicit filter-bar state per section +// Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one) +// Defaults match constitution: financirani=true + u-godišnjaku=true. +// User can uncheck either checkbox to broaden the result set. +// ════════════════════════════════════════════════════════════════════ +const _filters = { + klubovi: { financirani: true, godisnjak: true, hns_roster: false, total: 0 }, + sportasi: { priority: true, hns_profil: false, godina_od: null, godina_do: null, total: 0 }, + savezi: { financirani: true, total: 0 } +}; +function _filtersDefaults(sec){ + if(sec==='klubovi') return { financirani:true, godisnjak:true, hns_roster:false }; + if(sec==='sportasi') return { priority:true, hns_profil:false, godina_od:null, godina_do:null }; + if(sec==='savezi') return { financirani:true }; + return {}; +} +function _filtersReset(sec){ + Object.assign(_filters[sec], _filtersDefaults(sec)); + _filtersApply(sec); +} +function _filtersApply(sec){ + if(sec==='klubovi') { _cache.klubovi = null; loadKlubovi(); } + if(sec==='sportasi') { _cache.clanovi = null; loadSportasi(); } + if(sec==='savezi') { _cache.savezi = null; loadSavezi(); } +} +function _filtersBar(sec){ + // Returns HTML for the BUG-E filter-bar above the existing toolbar. + const f = _filters[sec] || {}; + const cnt = 'Prikazano: ' + + (f.shown||0) + ' od ' + (f.total||0) + ''; + if(sec==='klubovi'){ + return ` +
+ FILTER: + + + + + + ${cnt} +
`; + } + if(sec==='sportasi'){ + return ` +
+ FILTER: + + + + + + + ${cnt} +
`; + } + if(sec==='savezi'){ + return ` +
+ FILTER: + + + + ${cnt} +
`; + } + return ''; +} +function _filtersUpdateCount(sec, shown){ + _filters[sec].shown = shown; + const el = document.getElementById('bugE-cnt-'+sec); + if(el) el.textContent = 'Prikazano: '+shown+' od '+(_filters[sec].total||shown); +} +window._filters = _filters; +window._filtersReset = _filtersReset; +window._filtersApply = _filtersApply; + // === PGŽ priority filter (SUB6) — global helper, works across Klubovi/Savezi/Sportaši === window._pgz_filter_priority = window._pgz_filter_priority || false; window.togglePGZFilter = function(section){ @@ -1240,13 +1334,16 @@ async function loadSavezi(){ const root = $('#pg-savezi'); if(!_cache.savezi){ root.innerHTML = '
Učitavanje saveza…
'; - // PGŽ filter: switch to v2 priority-sort endpoint (only=true returns just PGŽ-relevant savezi) - const url = window._pgz_filter_priority + // BUG-E (2026-05-05): explicit filter — when financirani=true → priority-sort?only=true + const f = _filters.savezi; + const useOnly = f.financirani || window._pgz_filter_priority; + const url = useOnly ? '/v2/savezi/priority-sort?only=true&limit=500' - : '/savezi?limit=250'; + : '/v2/savezi/priority-sort?only=false&limit=500'; const d = await api(url); if(!d){ root.innerHTML='
Greška pri dohvatu
'; return; } _cache.savezi = d.rows || []; + _filters.savezi.total = (d.rows||[]).length; } renderSaveziShell(); applySaveziFilter(); @@ -1255,6 +1352,7 @@ function renderSaveziShell(){ const root = $('#pg-savezi'); const sports = Array.from(new Set((_cache.savezi||[]).map(s=>s.sport).filter(Boolean))).sort(); root.innerHTML = ` + ${_filtersBar('savezi')}
@@ -1376,7 +1474,7 @@ async function openSavez(id){ ${klubovi.length ? `
${klubovi.slice(0,100).map(k => ` - + @@ -1397,14 +1495,27 @@ async function loadKlubovi(){ const root = $('#pg-klubovi'); if(!_cache.klubovi){ root.innerHTML = '
Učitavanje klubova…
'; - // /api/klubovi already returns priority/financiran/godisnjak flags. - // When PGŽ filter is on, ask backend to only return priority klubs. - const url = window._pgz_filter_priority - ? '/klubovi?kategorija=priority&limit=2500' - : '/klubovi?limit=2500'; - const d = await api(url); + // BUG-E (2026-05-05): build /api/klubovi URL from explicit _filters.klubovi state. + // Defaults: financirani=true + godisnjak=true. When BOTH off → load all. + const f = _filters.klubovi; + const qs = new URLSearchParams(); + qs.set('limit','2500'); + qs.set('sort','financiran'); qs.set('order','desc'); // sort by potpore DESC (financiran flag) + // financirani + godisnjak combined with kategorija=priority logic: + if(f.financirani && f.godisnjak){ + qs.set('kategorija','priority'); // OR semantics → priority = financiran OR godišnjak + } else if(f.financirani){ + qs.set('financiran','true'); + } else if(f.godisnjak){ + qs.set('godisnjak','true'); + } + if(f.hns_roster) qs.set('samo_hns_roster','true'); + // Legacy global toggle still respected (if user clicks the old PGŽ button). + if(window._pgz_filter_priority && !qs.has('kategorija')) qs.set('kategorija','priority'); + const d = await api('/klubovi?'+qs.toString()); if(!d){ root.innerHTML='
Greška pri dohvatu
'; return; } _cache.klubovi = d.rows || []; + _filters.klubovi.total = (d.rows||[]).length; } renderKluboviShell(); applyKluboviFilter(); @@ -1414,6 +1525,7 @@ function renderKluboviShell(){ const sports = Array.from(new Set((_cache.klubovi||[]).map(k=>k.sport).filter(Boolean))).sort().slice(0,80); const grads = Array.from(new Set((_cache.klubovi||[]).map(k=>k.grad).filter(Boolean))).sort(); root.innerHTML = ` + ${_filtersBar('klubovi')}
@@ -1464,6 +1576,8 @@ function applyKluboviFilter(){ else if(kat==='financiran') rows = rows.filter(k => k.financiran); else if(kat==='godisnjak') rows = rows.filter(k => k.godisnjak); if(_sort.klubovi) rows = sortRows(rows, _sort.klubovi.key, _sort.klubovi.dir); + // BUG-E: live count + total + _filtersUpdateCount('klubovi', rows.length); $('#kl-cnt').textContent = rows.length+' klubova'; const top = rows.slice(0, 300); $('#kl-out').innerHTML = _state.viewKlubovi==='card' ? renderKluboviGrid(top) : renderKluboviTable(top); @@ -1638,7 +1752,7 @@ async function openKlub(id){ ${clanovi.length ? `
KlubRazinaGrad
${esc(k.klub||k.sport||'(bez naziva)')}${k.nositelj_kvalitete?' N.K.':''} ${txt(k.razina,'')} ${txt(k.grad)}
${clanovi.map(c => ` - + @@ -1664,7 +1778,7 @@ async function openKlub(id){
${esc(kat)} · ${groups[kat].length} igrač${groups[kat].length===1?'':'a'}
SportašSpolPozicijaKategorijaTagovi
${esc(c.ime||'')} ${esc(c.prezime||'')} ${txt(c.spol)} ${txt(c.pozicija)}
${groups[kat].map(c => ` - + @@ -2030,7 +2144,7 @@ async function openSportas(id){
${d.sport?''+esc(d.sport)+'':'—'} · ${txt(d.pozicija,'')} · - ${d.klub_id ? ''+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+'' : ''+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+''} + ${d.klub_id ? ''+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+'' : ''+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+''}
${dob ? '📅 '+fmtDate(dob)+'' : '📅 —'} @@ -2050,11 +2164,8 @@ async function openSportas(id){ ${d.pozicija?''+esc(d.pozicija)+'':''} ${d.broj_dresa?'#'+esc(d.broj_dresa)+'':''}
- + + @@ -2067,14 +2178,15 @@ async function openSportas(id){
${fmtNum(stats.sezone_aktivne||sezone.length)}
Sezona
- +
-
🏆 HNS Karijera (${sezone.length})
+
👤 Profil
+
🏆 HNS Karijera (${sezone.length})
📅 Utakmice (poslj. ${Math.min(utakmice.length,30)})
-
👤 Profil
+
🔗 Linkovi
-
+
${esc(c.ime||'')} ${esc(c.prezime||'')} ${txt(c.spol)} ${txt(c.pozicija)}
@@ -2124,7 +2236,7 @@ async function openSportas(id){
SezonaKlubNatjecanjeNastupiGoloviAsis.ŽutiCrv.Min.
` : '
Nema podataka o utakmicama
'}
- ` : ''} + + + ${enrichBlock('sportas', d.id)} `; openPanel('Sportaš · '+(d.ime||'')+' '+(d.prezime||''), html); @@ -3315,8 +3440,8 @@ function renderAlertPanel(a){
🔗 Povezani entiteti
- ${a.klub_id ? '' : ''} - ${a.clan_id ? '' : ''} + ${a.klub_id ? '' : ''} + ${a.clan_id ? '' : ''}
KlubKlub #'+a.klub_id+'Klikni za detalje →
SportašSportaš #'+a.clan_id+'Klikni za profil →
KlubKlub #'+a.klub_id+'Klikni za detalje →
SportašSportaš #'+a.clan_id+'Klikni za profil →
${(!a.klub_id && !a.clan_id) ? '
Nema povezanih entiteta u alarmu
' : ''} @@ -3455,32 +3580,123 @@ window.toggleSportasHNS = function(){ if(typeof loadSportasi === 'function') loadSportasi(); }; -// PANEL HISTORY STACK + NATRAG (CRISIS V7) +// PANEL HISTORY STACK + NATRAG (CRISIS V7 / BUG-B opener-based) +// Each entry: {opener: , args: [...]} — panelBack re-runs the previous opener. window._panelHistory = []; +window._panelDrilling = false; // suppress root-clear while drilling +window._panelSuppressPush = false; // suppress re-push when re-running for back nav +window._panelOpenerMap = {}; // name -> fn -window.pushPanelState = function(title, htmlContent, callback){ - // callback may be null — to render this state again later - window._panelHistory.push({title, htmlContent, callback}); - const back = document.getElementById('panel-back'); - if(back) back.style.display = window._panelHistory.length > 1 ? 'inline-flex' : 'none'; +window._registerOpener = function(fn){ + if(typeof fn !== 'function' || !fn.name) return null; + window._panelOpenerMap[fn.name] = fn; + return fn.name; }; +window._updateBackBtn = function(){ + const back = document.getElementById('panel-back'); + if(back) back.style.display = (window._panelHistory.length > 1) ? 'inline-flex' : 'none'; +}; + +// Push opener+args; record in browser history too (for browser back button) +window.pushPanelState = function(opener, args){ + if(window._panelSuppressPush) return; + const name = (typeof opener === 'function') ? window._registerOpener(opener) : (typeof opener === 'string' ? opener : null); + if(!name) return; + const top = window._panelHistory[window._panelHistory.length - 1]; + const sig = name + ':' + JSON.stringify(args || []); + if(top && (top.opener + ':' + JSON.stringify(top.args || [])) === sig) return; + window._panelHistory.push({opener: name, args: args || []}); + try { history.pushState({pgzPanel: true, depth: window._panelHistory.length}, '', location.href); } catch(e) {} + window._updateBackBtn(); +}; + +// panelDrill — push state then run opener (no panel close in between) +window.panelDrill = function(opener, ...args){ + window._panelDrilling = true; + try { + window.pushPanelState(opener, args); + const r = opener.apply(null, args); + if(r && typeof r.then === 'function') return r.finally(() => { window._panelDrilling = false; }); + return r; + } finally { + window._panelDrilling = false; + } +}; + +// panelOpen — root entry: clear history then drill +window.panelOpen = function(opener, ...args){ + window._panelHistory = []; + return window.panelDrill(opener, ...args); +}; + +// panelBack — pop current, re-run previous opener window.panelBack = function(){ - if(window._panelHistory.length <= 1){ closePanel(); return; } - // Remove current state + if(window._panelHistory.length <= 1){ window.closePanel(); return; } window._panelHistory.pop(); - // Restore previous const prev = window._panelHistory[window._panelHistory.length - 1]; - const t = document.getElementById('panel-hdr-t'); - const b = document.getElementById('panel-body'); - if(t) t.textContent = prev.title; - if(b) b.innerHTML = prev.htmlContent; - if(typeof prev.callback === 'function') prev.callback(); - const back = document.getElementById('panel-back'); - if(back) back.style.display = window._panelHistory.length > 1 ? 'inline-flex' : 'none'; + const fn = window._panelOpenerMap[prev.opener]; + if(typeof fn !== 'function'){ window.closePanel(); return; } + window._panelSuppressPush = true; + window._panelDrilling = true; + try { fn.apply(null, prev.args || []); } catch(e) { console.error('panelBack', e); } + setTimeout(() => { + window._panelSuppressPush = false; + window._panelDrilling = false; + window._updateBackBtn(); + }, 60); + window._updateBackBtn(); }; -// Override closePanel — clear history +// Browser back button → mirror panelBack while panel is open +window.addEventListener('popstate', function(){ + const panel = document.getElementById('panel'); + if(!panel || !panel.classList.contains('open')) return; + if(window._panelHistory.length > 1){ + window._panelSuppressPush = true; + window._panelDrilling = true; + window._panelHistory.pop(); + const prev = window._panelHistory[window._panelHistory.length - 1]; + const fn = window._panelOpenerMap[prev.opener]; + if(typeof fn === 'function') fn.apply(null, prev.args || []); + setTimeout(() => { + window._panelSuppressPush = false; + window._panelDrilling = false; + window._updateBackBtn(); + }, 60); + window._updateBackBtn(); + } else { + window.closePanel(); + } +}); + +// Wrap root open* functions: a non-drill call clears history and registers itself as root. +window._wrapOpener = function(name){ + const orig = window[name]; + if(typeof orig !== 'function' || orig.__pgzWrapped) return; + window._registerOpener(orig); + const wrapped = function(...args){ + if(!window._panelDrilling && !window._panelSuppressPush){ + window._panelHistory = []; + window._panelHistory.push({opener: name, args}); + try { history.pushState({pgzPanel: true, depth: 1}, '', location.href); } catch(e) {} + window._updateBackBtn(); + } + return orig.apply(this, args); + }; + wrapped.__pgzWrapped = true; + try { Object.defineProperty(wrapped, 'name', {value: name, configurable: true}); } catch(e) {} + window[name] = wrapped; + window._panelOpenerMap[name] = wrapped; +}; + +// Wrap all known root openers (idempotent) +['openSavez','openKlub','openSportas','openObjekt','openManif', + 'openPrimateljDetail','openProracunDrill','openSavezByName', + 'openMrezaNode','openForensicDetail','openOIB'] + .forEach(function(n){ try { window._wrapOpener(n); } catch(e) {} }); + +// Override closePanel — X button always returns to root: clear history & back btn window._origClosePanel = window.closePanel; window.closePanel = function(){ window._panelHistory = []; @@ -3489,7 +3705,7 @@ window.closePanel = function(){ const p = document.getElementById('panel'); if(p) p.classList.remove('open'); const ov = document.getElementById('panel-overlay'); - if(ov) ov.style.display = 'none'; + if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); } };