| |
| """ |
| Реєстр виконавчих проваджень ASVP |
| Пошук боржників за ПІБ · Регістр №28 Мін'юсту України |
| |
| Architecture (v8 — zero Gradio UI, MutationObserver-based state machine): |
| • Search input is a raw <input> — zero Gradio Textbox in visible UI. |
| • Hidden gr.Textbox + gr.Button are off-screen data bridges only. |
| • JS bridges: custom input → hidden Gradio textbox → hidden button click. |
| • Two-stage startup: HF download → local disk → DuckDB in-memory. |
| • LRU search cache — repeat queries return instantly. |
| • Status polling via gr.Timer (Gradio-native), NO custom fetch/API calls. |
| • Auto-retry from URL: MutationObserver on status bar → re-triggers search. |
| • Multi-field search: custom HTML pill toggles (CSV bridge via hidden Textbox). |
| • Match indicators: result cards highlight which fields matched. |
| • State machine v9: MutationObserver + Gradio class-change detection. |
| NO polling. Zero setInterval. Event-driven only. |
| """ |
|
|
| import gradio as gr |
| import duckdb |
| import glob |
| import html as html_mod |
| import os |
| import threading |
| import time |
| from collections import OrderedDict |
| from huggingface_hub import snapshot_download |
|
|
| |
| DATASET_REPO = "kontextox/asvp-debtors-dataset" |
| LOCAL_CACHE = "/data/asvp-dataset" |
| TITLE = "Реєстр боржників — ASVP" |
| MIN_QUERY_LEN = 3 |
| MAX_RESULTS = 100 |
| DB_THREADS = 4 |
| CACHE_MAX = 500 |
|
|
| WANTED_COLS = [ |
| "DEBTOR_NAME", "DEBTOR_BIRTHDATE", "DEBTOR_CODE", |
| "CREDITOR_NAME", "CREDITOR_CODE", "VP_ORDERNUM", "VP_BEGINDATE", |
| "VP_STATE", "ORG_NAME", "DVS_CODE", |
| "PHONE_NUM", "EMAIL_ADDR", "BANK_ACCOUNT", |
| ] |
|
|
| SEARCHABLE_FIELDS = OrderedDict([ |
| ("DEBTOR_NAME", "ПІБ боржника"), |
| ("CREDITOR_NAME", "ПІБ стягувача"), |
| ("VP_ORDERNUM", "Номер провадження"), |
| ("DEBTOR_CODE", "РНОКПП боржника"), |
| ("CREDITOR_CODE", "РНОКПП стягувача"), |
| ("DEBTOR_BIRTHDATE","Дата народження"), |
| ("VP_BEGINDATE", "Дата відкриття"), |
| ("VP_STATE", "Стан провадження"), |
| ("ORG_NAME", "Орган виконавця"), |
| ("DVS_CODE", "Код органу ДВС"), |
| ("PHONE_NUM", "Телефон"), |
| ("EMAIL_ADDR", "Email"), |
| ("BANK_ACCOUNT", "Банківський рахунок"), |
| ]) |
|
|
| DEFAULT_FIELDS = ["DEBTOR_NAME", "CREDITOR_NAME"] |
|
|
| HAS_TIMER = hasattr(gr, "Timer") |
|
|
| |
| _con: duckdb.DuckDBPyConnection | None = None |
| _lock = threading.Lock() |
| _ready = False |
| _error_msg: str | None = None |
| _status: str = "init" |
| _record_count: int = 0 |
|
|
| |
| _cache: OrderedDict[str, str] = OrderedDict() |
| _cache_lock = threading.Lock() |
|
|
|
|
| def _cache_get(key: str) -> str | None: |
| with _cache_lock: |
| if key in _cache: |
| _cache.move_to_end(key) |
| return _cache[key] |
| return None |
|
|
|
|
| def _cache_set(key: str, value: str) -> None: |
| with _cache_lock: |
| _cache[key] = value |
| _cache.move_to_end(key) |
| while len(_cache) > CACHE_MAX: |
| _cache.popitem(last=False) |
|
|
|
|
| |
|
|
|
|
| def _ensure_local_files() -> bool: |
| global _status |
| existing = glob.glob(os.path.join(LOCAL_CACHE, "data", "*.parquet")) |
| if existing: |
| print(f"[DB] Using {len(existing)} cached parquet files") |
| return True |
| _status = "downloading" |
| print(f"[DB] Downloading dataset to {LOCAL_CACHE} ...") |
| try: |
| snapshot_download( |
| repo_id=DATASET_REPO, |
| repo_type="dataset", |
| local_dir=LOCAL_CACHE, |
| max_workers=1, |
| ) |
| n = len(glob.glob(os.path.join(LOCAL_CACHE, "data", "*.parquet"))) |
| print(f"[DB] Downloaded {n} parquet files") |
| return True |
| except Exception as exc: |
| print(f"[DB] Download failed: {exc}") |
| return False |
|
|
|
|
| def _load_into_duckdb() -> None: |
| global _con, _ready, _status, _error_msg, _record_count |
| parquet_dir = os.path.join(LOCAL_CACHE, "data", "*.parquet") |
| if not glob.glob(parquet_dir): |
| _error_msg = f"Parquet files not found at {parquet_dir}" |
| _status = "error" |
| return |
| _status = "loading" |
| cols = ", ".join(WANTED_COLS) |
| try: |
| con = duckdb.connect(":memory:") |
| con.execute(f"PRAGMA threads={DB_THREADS}") |
| con.execute( |
| f"CREATE TABLE debtors AS SELECT {cols} FROM '{parquet_dir}'" |
| ) |
| _record_count = con.execute("SELECT COUNT(*) FROM debtors").fetchone()[0] |
| _con = con |
| _ready = True |
| _status = "ready" |
| print(f"[DB] Loaded {_record_count:,} records") |
| except Exception as exc: |
| _error_msg = str(exc) |
| _status = "error" |
| print(f"[DB] DuckDB load failed: {exc}") |
|
|
|
|
| def _init_db() -> None: |
| global _status, _error_msg |
| if not _ensure_local_files(): |
| _error_msg = "Не вдалося завантажити датасет з HuggingFace." |
| _status = "error" |
| return |
| _load_into_duckdb() |
|
|
|
|
| threading.Thread(target=_init_db, daemon=True, name="db-loader").start() |
|
|
| |
|
|
|
|
| def _esc(val) -> str: |
| if val is None: |
| return "—" |
| s = str(val) |
| if s.strip() in ("", "nan", "None", "NaN", "NaT"): |
| return "—" |
| return html_mod.escape(s) |
|
|
|
|
| def _plural_uk(n: int) -> str: |
| m10, m100 = n % 10, n % 100 |
| if m10 == 1 and m100 != 11: |
| return "результат" |
| if m10 in (2, 3, 4) and m100 not in (12, 13, 14): |
| return "результати" |
| return "результатів" |
|
|
|
|
| def _msg(cls: str, icon: str, title: str, body: str = "") -> str: |
| body_html = f"<p class='msg-body'>{body}</p>" if body else "" |
| return ( |
| f"<div class='msg {cls}'>" |
| f"<div class='msg-icon'>{icon}</div>" |
| f"<div class='msg-title'>{title}</div>" |
| f"{body_html}</div>" |
| ) |
|
|
|
|
| def _field(label: str, val, match: bool = False) -> str: |
| cls = "fld fld-match" if match else "fld" |
| return ( |
| f"<div class='{cls}'><div class='fld-label'>{label}</div>" |
| f"<div class='fld-value'>{_esc(val)}</div></div>" |
| ) |
|
|
|
|
| def _classify_state(raw) -> tuple[str, str]: |
| if raw is None or str(raw).strip() in ("", "nan", "None"): |
| return "", "" |
| vp = str(raw).strip().lower() |
| if "закін" in vp or "заверш" in vp: |
| return "state-closed", "Завершено" |
| if "припин" in vp: |
| return "state-suspended", "Припинено" |
| return "state-open", "Відкрито" |
|
|
|
|
| def _find_match_fields(row, query: str, fields: list[str]) -> set[str]: |
| q_low = query.lower().strip() |
| matched = set() |
| for col in fields: |
| val = row.get(col) |
| if val is None: |
| continue |
| s = str(val).lower() |
| if q_low in s: |
| matched.add(col) |
| return matched |
|
|
|
|
| |
|
|
|
|
| def _render_field_pills(selected: list[str] | None = None) -> str: |
| sel = set(selected or list(DEFAULT_FIELDS)) |
| pills = [] |
| for key, label in SEARCHABLE_FIELDS.items(): |
| active = " active" if key in sel else "" |
| pills.append( |
| f'<span class="fpill{active}" data-field="{key}">' |
| f'{html_mod.escape(label)}</span>' |
| ) |
| return '<div class="fpill-wrap">' + "".join(pills) + "</div>" |
|
|
|
|
| |
|
|
|
|
| def _render_results( |
| df, query: str, fields: list[str] | None = None, from_cache: bool = False |
| ) -> str: |
| n = len(df) |
| search_fields = fields or list(DEFAULT_FIELDS) |
| cache_tag = "<span class='cache-tag'>з кешу</span>" if from_cache else "" |
| field_tags = " ".join( |
| f"<span class='field-tag'>{SEARCHABLE_FIELDS.get(f, f)}</span>" |
| for f in search_fields |
| ) |
| h = ( |
| f"<div class='results-header'>" |
| f"<div class='results-header-left'>" |
| f"<span class='results-count'>{n} {_plural_uk(n)}</span>" |
| f"<span class='results-query'>для «{_esc(query)}»</span>" |
| f"</div>" |
| f"<div class='results-header-right'>" |
| f"{field_tags}" |
| f"{cache_tag}</div>" |
| f"</div>" |
| ) |
| for idx, r in df.iterrows(): |
| match_cols = _find_match_fields(r, query, search_fields) |
| state_cls, state_lbl = _classify_state(r.get("VP_STATE")) |
| state_badge = "" |
| if state_cls and _esc(r.get("VP_STATE")) != "—": |
| state_badge = f"<span class='badge {state_cls}'>{state_lbl}</span>" |
|
|
| match_badges = "" |
| if len(search_fields) > 1 and match_cols: |
| tags = "".join( |
| f"<span class='match-tag'>{SEARCHABLE_FIELDS.get(c, c)}</span>" |
| for c in search_fields |
| if c in match_cols |
| ) |
| match_badges = f"<div class='card-matches'>{tags}</div>" |
|
|
| h += ( |
| f"<div class='card' style='animation-delay:{idx * 25}ms'>" |
| f"<div class='card-top'>" |
| f"<div class='card-name'>{_esc(r.get('DEBTOR_NAME'))}</div>" |
| f"<div class='card-badges'>{state_badge}</div>" |
| f"</div>" |
| f"{match_badges}" |
| f"<div class='card-body'>" |
| f"{_field('Дата народження', r.get('DEBTOR_BIRTHDATE'), 'DEBTOR_BIRTHDATE' in match_cols)}" |
| f"{_field('РНОКПП боржника', r.get('DEBTOR_CODE'), 'DEBTOR_CODE' in match_cols)}" |
| f"{_field('ПІБ стягувача', r.get('CREDITOR_NAME'), 'CREDITOR_NAME' in match_cols)}" |
| f"{_field('РНОКПП стягувача', r.get('CREDITOR_CODE'), 'CREDITOR_CODE' in match_cols)}" |
| f"{_field('Номер провадження', r.get('VP_ORDERNUM'), 'VP_ORDERNUM' in match_cols)}" |
| f"{_field('Дата відкриття', r.get('VP_BEGINDATE'), 'VP_BEGINDATE' in match_cols)}" |
| f"{_field('Стан', r.get('VP_STATE'), 'VP_STATE' in match_cols)}" |
| f"{_field('Орган виконавця', r.get('ORG_NAME'), 'ORG_NAME' in match_cols)}" |
| f"{_field('Код органу ДВС', r.get('DVS_CODE'), 'DVS_CODE' in match_cols)}" |
| f"{_field('Телефон', r.get('PHONE_NUM'), 'PHONE_NUM' in match_cols)}" |
| f"{_field('Email', r.get('EMAIL_ADDR'), 'EMAIL_ADDR' in match_cols)}" |
| f"{_field('Рахунок', r.get('BANK_ACCOUNT'), 'BANK_ACCOUNT' in match_cols)}" |
| f"</div></div>" |
| ) |
| return h |
|
|
|
|
| def _welcome_html() -> str: |
| return _msg( |
| "hint", "⌕", "Введіть запит для пошуку", |
| "Оберіть поля для пошуку нижче. За замовчуванням — " |
| "ПІБ боржника. База налічує 13 колонок.", |
| ) |
|
|
|
|
| def _waiting_html(q: str) -> str: |
| return ( |
| "<div class='search-loader'>" |
| "<div class='loader-spinner'></div>" |
| "<div class='loader-label'>Очікування бази даних</div>" |
| f"<div class='loader-query-box'>«{_esc(q)}»</div>" |
| "<div class='loader-sub'>Пошук розпочнеться автоматично</div>" |
| "</div>" |
| ) |
|
|
|
|
| |
|
|
|
|
| def _validate_fields(fields) -> list[str]: |
| if not fields: |
| return list(DEFAULT_FIELDS) |
| valid = [f for f in fields if f in SEARCHABLE_FIELDS] |
| return valid if valid else list(DEFAULT_FIELDS) |
|
|
|
|
| def _parse_fields_csv(fields: str | None) -> list[str] | None: |
| """Parse a CSV string of field names into a list.""" |
| if not fields: |
| return None |
| return [f.strip() for f in fields.split(",") if f.strip()] if fields else None |
|
|
|
|
| def search(query: str, fields: list | None = None) -> str: |
| if not _ready: |
| if _status == "downloading": |
| return _msg("loading", "⏳", "Завантаження датасету", |
| "Це відбувається лише при першому запуску.") |
| if _status == "loading": |
| return _msg("loading", "⏳", "Завантаження в пам'ять") |
| if _error_msg: |
| return _msg("error", "✕", "Помилка", _esc(_error_msg)) |
| return _msg("loading", "⏳", "Ініціалізація") |
|
|
| q = (query or "").strip() |
| if len(q) < MIN_QUERY_LEN: |
| return _msg("hint", "ℹ", "Почніть пошук", |
| f"Введіть щонайменше {MIN_QUERY_LEN} символи.") |
|
|
| valid_fields = _validate_fields(fields) |
| cache_key = q.lower() + "|" + ",".join(sorted(valid_fields)) |
| cached = _cache_get(cache_key) |
| if cached is not None: |
| return cached |
|
|
| try: |
| with _lock: |
| cols = ", ".join(WANTED_COLS) |
| conditions = " OR ".join( |
| f"{col} ILIKE ?" for col in valid_fields |
| ) |
| params = [f"%{q}%" for _ in valid_fields] + [MAX_RESULTS] |
| df = _con.execute( |
| f"SELECT {cols} FROM debtors WHERE {conditions} LIMIT ?", |
| params, |
| ).df() |
| except Exception as exc: |
| return _msg("error", "✕", "Помилка пошуку", _esc(str(exc))) |
|
|
| if df.empty: |
| field_info = ", ".join( |
| SEARCHABLE_FIELDS.get(f, f) for f in valid_fields |
| ) |
| html = _msg( |
| "empty", "🔍", "Нічого не знайдено", |
| f"За запитом «{_esc(q)}» у полях {field_info} результатів не знайдено.", |
| ) |
| _cache_set(cache_key, html) |
| return html |
|
|
| html = _render_results(df, q, valid_fields) |
| _cache_set(cache_key, html) |
| return html |
|
|
|
|
| |
|
|
|
|
| def get_status() -> str: |
| """Return current status HTML.""" |
| if _ready: |
| return ( |
| f'<div class="status status--ready">' |
| f'<span class="status-pulse"></span> ' |
| f'{_record_count:,} записів</div>' |
| ) |
| if _status == "error": |
| return ( |
| f'<div class="status status--err">' |
| f'<span class="status-pulse"></span> Помилка завантаження</div>' |
| ) |
| return ( |
| '<div class="status status--wait">' |
| '<span class="status-pulse"></span> Завантаження бази даних …</div>' |
| ) |
|
|
|
|
| def _timer_tick(): |
| """Called by gr.Timer every 3s. Stops timer when DB is ready.""" |
| status_html = get_status() |
| if _ready: |
| return status_html, gr.update(active=False) |
| return status_html, gr.update() |
|
|
|
|
| def search_and_status(query: str, fields: str | None = None) -> tuple[str, str]: |
| """Search entry-point. Accepts fields as CSV string. Returns (results_html, status_html).""" |
| parsed_fields = _parse_fields_csv(fields) |
| results = search(query, parsed_fields) |
| status = get_status() |
| _ts = f'<!-- t:{time.time_ns()} -->' |
| return f'{_ts}{results}', status |
|
|
|
|
| |
|
|
|
|
| def _on_load(q: str, fields: str | None = None): |
| """Page-load handler: return pills + status + optional search results. Accepts fields as CSV string.""" |
| q_stripped = (q or "").strip() |
| parsed_fields = _parse_fields_csv(fields) |
| valid_fields = _validate_fields(parsed_fields) |
| pills_html = _render_field_pills(valid_fields) |
| status_html = get_status() |
| _ts = f'<!-- t:{time.time_ns()} -->' |
| if q_stripped and len(q_stripped) >= MIN_QUERY_LEN: |
| if _ready: |
| return pills_html, f'{_ts}{search(q_stripped, valid_fields)}', status_html |
| return pills_html, f'{_ts}{_waiting_html(q_stripped)}', status_html |
| return pills_html, f'{_ts}{_welcome_html()}', status_html |
|
|
|
|
| |
|
|
| CSS = """ |
| /* ══════════════ BASE RESET ══════════════ */ |
| *, *::before, *::after { box-sizing: border-box; } |
| html { scroll-behavior: smooth; } |
| html, body { |
| margin: 0; padding: 0; |
| background: #050505; |
| color: #e4e4e7; |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; |
| -webkit-font-smoothing: antialiased; |
| } |
| |
| /* ══════════════ NUKE GRADIO — kill everything ══════════════ */ |
| .gradio-container { |
| background: transparent !important; |
| max-width: 100% !important; |
| padding: 0 !important; |
| overflow-x: hidden; |
| } |
| .gradio-container, |
| .gradio-container * { |
| pointer-events: none !important; |
| } |
| .gradio-container > footer, |
| footer, |
| .gradio-container .gr-footer { display: none !important; } |
| .gradio-container .contain, |
| .gradio-container .main, |
| .gradio-container .gr-block, |
| .gradio-container .gr-box, |
| .gradio-container .gr-form, |
| .gradio-container .gr-row, |
| .gradio-container .gr-column, |
| .gradio-container .gr-panel, |
| .gradio-container .gr-gap, |
| .gradio-container > .gr-block-container, |
| .gradio-container > .gr-block-container > .main { |
| border: none !important; |
| background: transparent !important; |
| box-shadow: none !important; |
| max-width: 100% !important; |
| padding: 0 !important; |
| margin: 0 !important; |
| gap: 0 !important; |
| outline: none !important; |
| } |
| .gradio-container .wrap { gap: 0 !important; } |
| .gradio-container .gr-padding { padding: 0 !important; } |
| .gradio-container .label-wrap, |
| .gradio-container label { display: none !important; } |
| .gradio-container .toast-wrap, |
| .gradio-container .toast, |
| .gradio-container .toast-body, |
| .gradio-container .gr-progress, |
| .gradio-container .eta-bar, |
| .gradio-container .progress-text, |
| .gradio-container .progress-bar, |
| .gradio-container .generating, |
| .gradio-container .gr-loading, |
| .gradio-container .loading-wrap, |
| .gradio-container .lds-ellipsis, |
| .gradio-container .gr-button-loading, |
| .gradio-container .gr-pending, |
| .gradio-container [data-testid="pending"], |
| .gradio-container [data-testid="status-tracker"], |
| .gradio-container .gr-loading-container, |
| .gradio-container .gr-loading-wrapper, |
| .gradio-container .gr-spinner, |
| .gradio-container .indicator { display: none !important; } |
| /* Kill Gradio 6 semi-transparent loading overlays on output components */ |
| .gradio-container .gradio-html::after, |
| .gradio-container .gradio-html .prose::after, |
| .gradio-container [data-testid="html"]::after, |
| .gradio-container .gr-component::after, |
| .gradio-container .component-wrapper::after { |
| display: none !important; |
| content: none !important; |
| background: none !important; |
| opacity: 0 !important; |
| pointer-events: none !important; |
| } |
| .gradio-container .gradio-html.is-generating, |
| .gradio-container .gradio-html.pending, |
| .gradio-container .gr-component.is-generating, |
| .gradio-container .component-wrapper.is-generating { |
| opacity: 1 !important; |
| } |
| .gradio-container .gr-block::before, |
| .gradio-container .gr-block::after, |
| .gradio-container .gr-box::before, |
| .gradio-container .gr-box::after, |
| .gradio-container .gr-panel::before, |
| .gradio-container .gr-panel::after { |
| display: none !important; |
| content: none !important; |
| } |
| .gradio-container .hide { |
| display: none !important; |
| visibility: hidden !important; |
| pointer-events: none !important; |
| position: static !important; |
| } |
| |
| /* ══════════════ HIDDEN BRIDGE COMPONENTS ══════════════ */ |
| #q-hidden, |
| #fields-hidden, |
| #search-trigger { |
| position: fixed !important; |
| left: -99999px !important; |
| top: -99999px !important; |
| width: 1px !important; |
| height: 1px !important; |
| opacity: 0 !important; |
| overflow: hidden !important; |
| clip: rect(0, 0, 0, 0) !important; |
| border: none !important; |
| padding: 0 !important; |
| margin: 0 !important; |
| pointer-events: none !important; |
| } |
| |
| /* ══════════════ TIMER COMPONENT ══════════════ */ |
| .gradio-container .gr-timer, |
| [id$="status-timer"] { |
| display: none !important; |
| position: fixed !important; |
| left: -99999px !important; |
| top: -99999px !important; |
| width: 0 !important; |
| height: 0 !important; |
| overflow: hidden !important; |
| pointer-events: none !important; |
| } |
| |
| /* ══════════════ RE-ENABLE pointer-events on our custom HTML ══════════════ */ |
| #search-bar, |
| #search-bar *, |
| #search-input, |
| #search-btn, |
| #fields, |
| #fields *, |
| #res, |
| #res *, |
| #top-loader, |
| .fpill, |
| .card, |
| .retry-btn, |
| a { |
| pointer-events: auto !important; |
| } |
| |
| /* ══════════════ STATUS INDICATOR ══════════════ */ |
| #status-bar { pointer-events: none !important; } |
| .status { |
| display: flex; align-items: center; justify-content: center; |
| gap: 8px; padding: 16px 24px; |
| font-size: 12px; color: #52525b; font-weight: 400; |
| letter-spacing: 0.02em; user-select: none; |
| } |
| .status--ready { color: #3f3f46; } |
| .status--wait { color: #52525b; } |
| .status--err { color: #7f1d1d; } |
| .status-pulse { |
| display: inline-block; width: 6px; height: 6px; |
| border-radius: 50%; background: #52525b; |
| animation: statusPulse 2s ease-in-out infinite; |
| } |
| .status--ready .status-pulse { background: #22c55e; animation: none; opacity: .6; } |
| .status--err .status-pulse { background: #ef4444; animation: none; } |
| @keyframes statusPulse { 0%,100%{opacity:.3} 50%{opacity:1} } |
| |
| /* ══════════════ HEADER ══════════════ */ |
| .hdr { text-align: center; padding: 48px 24px 0; pointer-events: none !important; } |
| .hdr h1 { |
| font-size: 40px; font-weight: 700; color: #fafafa; |
| margin: 0 0 12px; letter-spacing: -0.04em; line-height: 1.1; |
| } |
| .hdr p { font-size: 14px; color: #3f3f46; margin: 0; line-height: 1.5; } |
| |
| /* ══════════════ SEARCH BAR — pure custom HTML, zero Gradio ══════════════ */ |
| .srow { |
| max-width: 580px; width: 100%; |
| margin: 24px auto 0; |
| padding: 0 24px; |
| display: flex; |
| align-items: stretch; |
| gap: 0; |
| position: relative; |
| z-index: 99999; |
| } |
| .srow-input-wrap { |
| flex: 1; min-width: 0; |
| position: relative; |
| } |
| .srow-icon { |
| position: absolute; |
| left: 16px; top: 50%; transform: translateY(-50%); |
| font-size: 18px; color: #3f3f46; |
| pointer-events: none; z-index: 2; |
| line-height: 1; |
| } |
| #search-input { |
| display: block; |
| width: 100%; font-size: 15px; |
| height: 50px; line-height: 50px; |
| border-radius: 12px 0 0 12px; |
| border: 1px solid #1c1c1e; |
| border-right: none; |
| background: #0f0f11; color: #fafafa; |
| padding: 0 0 0 44px; margin: 0; |
| outline: none; box-shadow: none; |
| transition: border-color .2s, background .2s, box-shadow .2s; |
| font-family: inherit; |
| -webkit-font-smoothing: inherit; |
| caret-color: #e4e4e7; |
| } |
| #search-input:focus { |
| border-color: #2a2a2e; background: #111114; |
| } |
| #search-input::placeholder { color: #333338; } |
| #search-input.readonly { |
| cursor: wait; |
| opacity: .7; |
| } |
| #search-btn { |
| height: 50px; width: 90px; min-width: 90px; max-width: 90px; |
| border-radius: 0 12px 12px 0; |
| font-size: 14px; font-weight: 500; |
| padding: 0; margin: 0; |
| border: 1px solid #1c1c1e; border-left: none; |
| background: #16161a; color: #a1a1aa; |
| cursor: pointer; transition: all .15s; |
| letter-spacing: .01em; white-space: nowrap; |
| font-family: inherit; |
| -webkit-font-smoothing: inherit; |
| } |
| #search-btn:hover { background: #1e1e24; color: #e4e4e7; } |
| #search-btn:active { background: #0f0f11; } |
| /* ── Loading state: PROMINENT visual feedback ── */ |
| #search-input.loading { |
| border-color: rgba(99,102,241,.45) !important; |
| box-shadow: 0 0 0 2px rgba(99,102,241,.15), 0 0 30px -4px rgba(99,102,241,.2) !important; |
| background: rgba(99,102,241,.03) !important; |
| } |
| #search-btn.loading { |
| color: #6366f1 !important; |
| background: rgba(99,102,241,.08) !important; |
| box-shadow: 0 0 20px -2px rgba(99,102,241,.15) !important; |
| border-color: rgba(99,102,241,.2) !important; |
| pointer-events: none !important; |
| cursor: wait !important; |
| } |
| .srow-progress { |
| display: none; |
| height: 3px; |
| background: #0a0a0d; |
| border-radius: 0 0 0 12px; |
| overflow: hidden; |
| margin-top: -1px; |
| } |
| .srow.loading .srow-progress { |
| display: block; |
| } |
| .srow-progress-bar { |
| height: 100%; |
| width: 40%; |
| background: linear-gradient(90deg, transparent, #6366f1, #818cf8, transparent); |
| animation: progressSlide 1s ease-in-out infinite; |
| border-radius: 2px; |
| } |
| @keyframes progressSlide { |
| 0% { transform: translateX(-150%); } |
| 100% { transform: translateX(350%); } |
| } |
| |
| /* ══════════════ FIELD PILLS ══════════════ */ |
| #fields .html-wrap { display: block !important; } |
| .fpill-wrap { |
| max-width: 700px; width: 100%; |
| margin: 12px auto 0; |
| padding: 0 24px; |
| display: flex; flex-wrap: wrap; gap: 0; |
| line-height: 1; |
| } |
| .fpill { |
| display: inline-flex; |
| align-items: center; |
| height: 28px; |
| padding: 0 12px; |
| margin: 0 4px 6px 0; |
| border-radius: 100px; |
| border: 1px solid #16161a; |
| background: #0a0a0d; |
| color: #3f3f46; |
| font-size: 12px; |
| font-weight: 400; |
| cursor: pointer; |
| transition: all .15s; |
| user-select: none; |
| line-height: 1; |
| white-space: nowrap; |
| } |
| .fpill:hover { border-color: #222226; color: #52525b; } |
| .fpill.active { background: #141418; color: #a1a1aa; border-color: #27272a; } |
| |
| /* ══════════════ TOP LOADING BAR ══════════════ */ |
| #top-loader { |
| position: fixed; top: 0; left: 0; width: 100%; height: 3px; |
| background: linear-gradient(90deg, transparent 0%, #6366f1 20%, #818cf8 50%, #6366f1 80%, transparent 100%); |
| background-size: 200% 100%; |
| z-index: 999999; pointer-events: none; |
| opacity: 0; transition: opacity .15s; |
| } |
| #top-loader.active { |
| opacity: 1; |
| animation: topLoaderSlide 1.2s ease-in-out infinite; |
| } |
| @keyframes topLoaderSlide { |
| 0% { background-position: 200% 0; } |
| 100% { background-position: -200% 0; } |
| } |
| |
| /* ══════════════ SEARCH LOADER ══════════════ */ |
| .search-loader { |
| display: flex; flex-direction: column; align-items: center; justify-content: center; |
| padding: 80px 20px 72px; gap: 16px; |
| min-height: 320px; |
| animation: loaderFadeIn .3s ease; |
| } |
| @keyframes loaderFadeIn { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .loader-spinner { |
| width: 42px; height: 42px; |
| border: 3px solid #1a1a22; border-top-color: #6366f1; |
| border-right-color: #4f46e5; |
| border-radius: 50%; animation: spin .7s linear infinite; |
| box-shadow: 0 0 20px -2px rgba(99,102,241,.35); |
| } |
| .loader-label { |
| font-size: 16px; font-weight: 600; color: #a5b4fc; |
| text-align: center; |
| letter-spacing: .02em; |
| } |
| .loader-query-box { |
| display: inline-block; |
| margin-top: 4px; |
| padding: 8px 20px; |
| border-radius: 10px; |
| border: 1px solid rgba(99,102,241,.2); |
| background: rgba(99,102,241,.06); |
| font-size: 15px; |
| color: #e0e7ff; |
| letter-spacing: .01em; |
| text-align: center; |
| } |
| .loader-sub { |
| font-size: 13px; color: #71717a; max-width: 320px; |
| text-align: center; line-height: 1.5; |
| } |
| .loader-elapsed { |
| font-size: 13px; color: #818cf8; margin-top: 2px; |
| font-variant-numeric: tabular-nums; letter-spacing: .04em; |
| font-weight: 500; |
| } |
| .retry-btn { |
| margin-top: 10px; padding: 6px 16px; |
| border-radius: 8px; border: 1px solid #1c1c1e; |
| background: #0f0f11; color: #71717a; |
| font-size: 13px; cursor: pointer; transition: all .15s; |
| } |
| .retry-btn:hover { background: #16161a; color: #a1a1aa; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| @keyframes fadeIn { from { opacity: .5; } to { opacity: 1; } } |
| @keyframes msgSlide { from { opacity:.4; transform:translateY(4px); } to { opacity:1; transform:translateY(0); } } |
| |
| /* ══════════════ PROMINENT SEARCH LOADER ══════════════ */ |
| .loader-ring-wrap { |
| position: relative; |
| width: 88px; height: 88px; |
| display: flex; align-items: center; justify-content: center; |
| } |
| .loader-ring { |
| position: absolute; |
| width: 88px; height: 88px; |
| border: 3px solid rgba(99,102,241,.15); |
| border-top-color: #818cf8; |
| border-right-color: #6366f1; |
| border-radius: 50%; |
| animation: spin .8s linear infinite; |
| box-shadow: 0 0 40px -2px rgba(99,102,241,.5), inset 0 0 24px -2px rgba(99,102,241,.2); |
| } |
| .loader-ring-inner { |
| position: absolute; |
| width: 60px; height: 60px; |
| border: 2px solid rgba(99,102,241,.1); |
| border-bottom-color: #a5b4fc; |
| border-left-color: #818cf8; |
| border-radius: 50%; |
| animation: spin 1.2s linear infinite reverse; |
| } |
| .loader-hint { |
| font-size: 13px; color: #6366f1; |
| letter-spacing: .04em; |
| text-align: center; |
| } |
| |
| /* ══════════════ RESULTS ══════════════ */ |
| #res { max-width: 800px; margin: 0 auto; padding: 0 24px 40px; min-height: 100px; } |
| .results-header { |
| display: flex; align-items: center; justify-content: space-between; |
| flex-wrap: wrap; gap: 8px; |
| padding: 32px 0 20px; border-bottom: 1px solid #111115; margin-bottom: 12px; |
| } |
| .results-header-left { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| .results-header-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } |
| .results-count { font-size: 13px; font-weight: 500; color: #71717a; } |
| .results-query { font-size: 13px; color: #3f3f46; } |
| .cache-tag { |
| font-size: 11px; font-weight: 500; color: #3f3f46; |
| background: #111115; padding: 2px 8px; |
| border-radius: 100px; letter-spacing: .03em; |
| } |
| .field-tag { |
| font-size: 10px; font-weight: 500; color: #3f3f46; |
| background: #0c0c0f; padding: 2px 7px; |
| border-radius: 100px; letter-spacing: .02em; |
| border: 1px solid #131316; |
| } |
| |
| /* ══════════════ CARDS ══════════════ */ |
| .card { |
| background: #0a0a0d; border: 1px solid #131316; |
| border-radius: 14px; padding: 22px 26px; margin-bottom: 8px; |
| transition: border-color .15s, transform .15s; |
| animation: cardIn .25s ease both; |
| } |
| .card:hover { border-color: #1e1e24; transform: translateY(-1px); } |
| @keyframes cardIn { from { opacity:.5; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } } |
| .card-top { |
| display: flex; align-items: center; justify-content: space-between; |
| gap: 12px; margin-bottom: 16px; padding-bottom: 14px; |
| border-bottom: 1px solid #0f0f12; |
| } |
| .card-name { font-size: 17px; font-weight: 600; color: #fafafa; letter-spacing: -.01em; line-height: 1.4; } |
| .card-badges { display: flex; align-items: center; gap: 6px; flex-shrink: 0; flex-wrap: wrap; } |
| .badge { |
| font-size: 11px; font-weight: 500; padding: 3px 10px; |
| border-radius: 100px; white-space: nowrap; flex-shrink: 0; letter-spacing: .02em; |
| } |
| .state-open { background: rgba(34,197,94,.06); color: #22c55e; border: 1px solid rgba(34,197,94,.12); } |
| .state-suspended { background: rgba(234,179,8,.06); color: #eab308; border: 1px solid rgba(234,179,8,.12); } |
| .state-closed { background: rgba(82,82,91,.06); color: #52525b; border: 1px solid rgba(82,82,91,.12); } |
| .card-matches { |
| display: flex; flex-wrap: wrap; gap: 4px; |
| margin-bottom: 14px; padding-bottom: 12px; |
| border-bottom: 1px solid #0f0f12; |
| } |
| .match-tag { |
| font-size: 10px; font-weight: 500; |
| padding: 2px 8px; border-radius: 100px; |
| background: rgba(99,102,241,.06); color: #818cf8; |
| border: 1px solid rgba(99,102,241,.12); letter-spacing: .02em; |
| } |
| .card-body { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 32px; } |
| .fld-label { font-size: 10px; font-weight: 500; color: #333338; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 2px; } |
| .fld-value { font-size: 13px; color: #a1a1aa; word-break: break-word; line-height: 1.5; } |
| .fld-match { background: rgba(99,102,241,.03); border-radius: 6px; padding: 4px 8px; margin: -4px -8px; } |
| .fld-match .fld-label { color: #6366f1; } |
| .fld-match .fld-value { color: #c7d2fe; } |
| |
| /* ══════════════ MESSAGES ══════════════ */ |
| .msg { |
| text-align: center; padding: 40px 20px; |
| display: flex; flex-direction: column; align-items: center; |
| gap: 8px; animation: msgSlide .25s ease both; |
| } |
| .msg-icon { font-size: 32px; color: #1a1a1e; line-height: 1; margin-bottom: 4px; } |
| .msg-title { font-size: 16px; font-weight: 500; color: #52525b; } |
| .msg-body { font-size: 14px; color: #333338; line-height: 1.6; max-width: 400px; margin: 0; } |
| .msg.empty .msg-icon { color: #1a1a1e; } |
| .msg.error .msg-icon { color: #7f1d1d; } |
| .msg.error .msg-title { color: #ef4444; } |
| .msg.hint .msg-icon { color: #1a1a1e; } |
| .msg.loading .msg-icon { color: #27272a; animation: spin 1.5s linear infinite; display: inline-block; } |
| |
| ::selection { background: rgba(99,102,241,.2); color: #fafafa; } |
| |
| /* ══════════════ RESPONSIVE ══════════════ */ |
| @media (max-width: 640px) { |
| .hdr { padding: 32px 16px 0; } |
| .hdr h1 { font-size: 28px; } |
| .srow { padding: 0 16px; flex-direction: column; } |
| #search-input { |
| border-radius: 12px 12px 0 0; |
| border-right: 1px solid #1c1c1e; |
| border-bottom: none; |
| } |
| #search-btn { |
| border-radius: 0 0 12px 12px; |
| border-left: 1px solid #1c1c1e; |
| max-width: 100%; width: 100%; height: 44px; |
| } |
| .fpill-wrap { padding: 0 16px; } |
| .card { padding: 18px 20px; border-radius: 12px; } |
| .card-body { grid-template-columns: 1fr; gap: 10px 0; } |
| .card-top { flex-direction: column; align-items: flex-start; gap: 8px; } |
| .results-header { padding-top: 24px; } |
| .results-header-left, .results-header-right { width: 100%; } |
| #res { padding: 0 16px 80px; } |
| .fld-match { padding: 4px 6px; margin: -4px -6px; } |
| } |
| """ |
|
|
| |
|
|
| _SEARCH_BAR_HTML = ( |
| '<div class="srow" id="search-bar">' |
| '<div class="srow-input-wrap">' |
| '<span class="srow-icon">\u2315</span>' |
| '<input type="text" id="search-input" placeholder="\u041f\u0406\u0411, \u043d\u043e\u043c\u0435\u0440 \u043f\u0440\u043e\u0432\u0430\u0434\u0436\u0435\u043d\u043d\u044f, \u043a\u043e\u0434 \u2026" autocomplete="off" autofocus />' |
| '</div>' |
| '<button type="button" id="search-btn">\u0417\u043d\u0430\u0439\u0442\u0438</button>' |
| '<div class="srow-progress"><div class="srow-progress-bar"></div></div>' |
| '</div>' |
| '<div id="top-loader"></div>' |
| ) |
|
|
| |
|
|
| |
| |
| |
| _SKELETON_JS = """(q, _fieldsCSV) => { |
| var fields = []; |
| document.querySelectorAll('.fpill.active').forEach(function(p) { |
| fields.push(p.dataset.field); |
| }); |
| if (!fields.length) fields = ['DEBTOR_NAME', 'CREDITOR_NAME']; |
| |
| /* Always read from visible custom input — never trust the Gradio param */ |
| var ci = document.getElementById('search-input'); |
| q = ci ? ci.value.trim() : (q || '').trim(); |
| |
| return [q || '', fields.join(',')]; |
| }""" |
|
|
| |
| _JS_READ_URL = """() => { |
| var params = new URLSearchParams(location.search); |
| var q = params.get('q'); |
| var f = params.get('f'); |
| if (!q || !f) { |
| var h = location.hash; |
| var qm = h.match(/[?&]q=([^&]+)/); |
| var fm = h.match(/[?&]f=([^&]+)/); |
| if (qm) q = qm[1]; |
| if (fm) f = fm[1]; |
| } |
| var decoded = q ? decodeURIComponent(q) : ''; |
| var fieldsCSV = f ? f : 'DEBTOR_NAME,CREDITOR_NAME'; |
| return [decoded, fieldsCSV]; |
| }""" |
|
|
| |
|
|
| _HEAD_JS = """ |
| <script> |
| (function () { |
| /* |
| * ══════════════════════════════════════════════════════════════════ |
| * STATE MACHINE v9 — Event-driven, zero-poll design |
| * ══════════════════════════════════════════════════════════════════ |
| * |
| * States: |
| * IDLE — normal state, input enabled |
| * SEARCHING — search in progress, input locked, animation shown |
| * |
| * Transition triggers: |
| * IDLE → SEARCHING: user clicks search or auto-retry fires |
| * SEARCHING → IDLE: Svelte updates #res (MutationObserver detects) |
| * OR Gradio removes .is-generating/.pending |
| * OR safety timeout (30s) |
| * |
| * Detection strategy (DUAL, no polling): |
| * 1. PRIMARY: MutationObserver on content wrapper watches for |
| * our unique marker element being removed. |
| * This fires when Svelte replaces innerHTML with results. |
| * 2. SECONDARY: MutationObserver on #res watches for Gradio's |
| * .is-generating/.pending class being removed. |
| * This fires when Gradio finishes server round-trip. |
| * 3. FALLBACK: Safety timeout (30s) as last resort. |
| * |
| * Edge cases handled: |
| * - 2nd+ search: content wrapper found robustly, old results replaced |
| * - Checkbox toggle: no search triggered, pills update instantly |
| * - URL params on load: cosmetic skeleton shown, no enterLoading |
| * - DB not ready: waiting message shown, auto-retry on DB ready |
| * - Cached results: fast response, animation shown briefly then removed |
| * - No results: "nothing found" message, animation removed correctly |
| * - Error: error message, animation removed correctly |
| * - Empty/short query: blocked client-side, no loading state entered |
| */ |
| |
| var _autoTriggered = false; |
| window._searchActive = false; |
| window._searchStartTime = 0; |
| var _searchId = 0; |
| |
| /* Observer references for cleanup */ |
| var _contentObserver = null; |
| var _classObserver = null; |
| var _safetyTimer = null; |
| var _elapsedTimer = null; |
| var _elapsedStart = 0; |
| |
| /* ── Fix Gradio postMessage cross-origin error ── */ |
| try { |
| var _origPM = window.postMessage; |
| window.postMessage = function(msg, targetOrigin, transfer) { |
| try { return _origPM.call(this, msg, targetOrigin, transfer); } |
| catch(e) {} |
| }; |
| } catch(e) {} |
| |
| /* ── helpers ────────────────────────────── */ |
| |
| function _readUrlQuery() { |
| var params = new URLSearchParams(location.search); |
| var q = params.get('q'); |
| if (q) return decodeURIComponent(q); |
| var h = location.hash; |
| var m = h.match(/[?&]q=([^&]+)/); |
| return m ? decodeURIComponent(m[1]) : null; |
| } |
| |
| function _readUrlFields() { |
| var params = new URLSearchParams(location.search); |
| var f = params.get('f'); |
| if (f) return f.split(',').map(function(s) { return decodeURIComponent(s.trim()); }); |
| var h = location.hash; |
| var m = h.match(/[?&]f=([^&]+)/); |
| return m ? m[1].split(',').map(function(s) { return decodeURIComponent(s.trim()); }) |
| : ['DEBTOR_NAME', 'CREDITOR_NAME']; |
| } |
| |
| function _escHtml(s) { |
| return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| } |
| |
| /* ── Field labels for dynamic page title ── */ |
| var _FIELD_LABELS = { |
| 'DEBTOR_NAME': '\\u041f\\u0406\\u0411 \\u0431\\u043e\\u0440\\u0436\\u043d\\u0438\\u043a\\u0430', |
| 'CREDITOR_NAME': '\\u041f\\u0406\\u0411 \\u0441\\u0442\\u044f\\u0433\\u0443\\u0432\\u0430\\u0447\\u0430', |
| 'VP_ORDERNUM': '\\u041d\\u043e\\u043c\\u0435\\u0440 \\u043f\\u0440\\u043e\\u0432\\u0430\\u0434\\u0436\\u0435\\u043d\\u043d\\u044f', |
| 'DEBTOR_CODE': '\\u0420\\u041d\\u041e\\u041a\\u041f\\u041f \\u0431\\u043e\\u0440\\u0436\\u043d\\u0438\\u043a\\u0430', |
| 'CREDITOR_CODE': '\\u0420\\u041d\\u041e\\u041a\\u041f\\u041f \\u0441\\u0442\\u044f\\u0433\\u0443\\u0432\\u0430\\u0447\\u0430', |
| 'DEBTOR_BIRTHDATE': '\\u0414\\u0430\\u0442\\u0430 \\u043d\\u0430\\u0440\\u043e\\u0434\\u0436\\u0435\\u043d\\u043d\\u044f', |
| 'VP_BEGINDATE': '\\u0414\\u0430\\u0442\\u0430 \\u0432\\u0456\\u0434\\u043a\\u0440\\u0438\\u0442\\u0442\\u044f', |
| 'VP_STATE': '\\u0421\\u0442\\u0430\\u043d \\u043f\\u0440\\u043e\\u0432\\u0430\\u0434\\u0436\\u0435\\u043d\\u043d\\u044f', |
| 'ORG_NAME': '\\u041e\\u0440\\u0433\\u0430\\u043d \\u0432\\u0438\\u043a\\u043e\\u043d\\u0430\\u0432\\u0446\\u044f', |
| 'DVS_CODE': '\\u041a\\u043e\\u0434 \\u0414\\u0412\\u0421', |
| 'PHONE_NUM': '\\u0422\\u0435\\u043b\\u0435\\u0444\\u043e\\u043d', |
| 'EMAIL_ADDR': 'Email', |
| 'BANK_ACCOUNT': '\\u0420\\u0430\\u0445\\u0443\\u043d\\u043e\\u043a' |
| }; |
| |
| function _updateTitle(q, fields) { |
| if (!q) { document.title = '\\u0420\\u0435\\u0454\\u0441\\u0442\\u0440 \\u0431\\u043e\\u0440\\u0436\\u043d\\u0438\\u043a\\u0456\\u0432 \\u2014 ASVP'; return; } |
| var labels = (fields || []).map(function(f) { return _FIELD_LABELS[f] || f; }); |
| var fieldStr = labels.join('/') || '\\u041f\\u043e\\u0448\\u0443\\u043a'; |
| document.title = fieldStr + ': ' + q + ' \\u2014 \\u0420\\u0435\\u0454\\u0441\\u0442\\u0440 \\u0431\\u043e\\u0440\\u0436\\u043d\\u0438\\u043a\\u0456\\u0432 \\u2014 ASVP'; |
| } |
| window._updateTitle = _updateTitle; |
| |
| /* ── GRADIO BRIDGE FUNCTIONS ── */ |
| |
| /* Set native value on a Gradio hidden textarea/input, triggering reactivity */ |
| function _setGradioValue(elemId, value) { |
| var gradioTA = document.querySelector('#' + elemId + ' textarea, #' + elemId + ' input'); |
| if (!gradioTA) return; |
| try { |
| var proto = gradioTA.tagName === 'TEXTAREA' |
| ? window.HTMLTextAreaElement.prototype |
| : window.HTMLInputElement.prototype; |
| var desc = Object.getOwnPropertyDescriptor(proto, 'value'); |
| if (desc && desc.set) { |
| desc.set.call(gradioTA, value); |
| } else { |
| gradioTA.value = value; |
| } |
| gradioTA.dispatchEvent(new Event('input', { bubbles: true })); |
| gradioTA.dispatchEvent(new Event('change', { bubbles: true })); |
| } catch(e) { |
| gradioTA.value = value; |
| } |
| } |
| |
| function _syncFieldsToGradio(fieldsCSV) { |
| _setGradioValue('fields-hidden', fieldsCSV); |
| } |
| |
| function _triggerGradioSearch() { |
| _setGradioValue('q-hidden', document.getElementById('search-input').value || ''); |
| setTimeout(function() { |
| var trigger = document.querySelector('#search-trigger button, #search-trigger'); |
| if (trigger) trigger.click(); |
| }, 30); |
| } |
| |
| /* ── ROBUST CONTENT WRAPPER DETECTION ── */ |
| /* |
| * Gradio 6's HTML component wraps content in .html-wrap or .prose. |
| * We must find the correct element to inject skeleton into. |
| * Fallback strategy tries multiple selectors and heuristics. |
| */ |
| function _getContentWrapper() { |
| var resEl = document.getElementById('res'); |
| if (!resEl) return null; |
| |
| /* Strategy 1: Known Gradio inner wrappers */ |
| var w = resEl.querySelector('.html-wrap'); |
| if (w) return w; |
| |
| w = resEl.querySelector('.prose'); |
| if (w) return w; |
| |
| /* Strategy 2: First direct child that is a visible div (not label/wrapper chrome) */ |
| for (var c = resEl.firstElementChild; c; c = c.nextElementSibling) { |
| if (c.tagName === 'LABEL') continue; |
| var cls = (c.className || ''); |
| if (cls.indexOf('label-wrap') >= 0) continue; |
| /* Accept any div that has actual content (children or text) */ |
| if (c.tagName === 'DIV' && (c.children.length > 0 || c.textContent.length > 10)) { |
| return c; |
| } |
| } |
| |
| /* Strategy 3: Last resort — the #res element itself */ |
| return resEl; |
| } |
| |
| /* ── URL + TITLE UPDATE ── */ |
| |
| /* |
| * Notify parent/top frames of URL change. |
| * HuggingFace Spaces embeds in an iframe at huggingface.co. |
| * We try multiple message formats since HF's listener API is undocumented. |
| */ |
| function _notifyParent(url) { |
| var fullUrl = location.origin + url; |
| var msgs = [ |
| {type: 'url_change', url: url}, |
| {type: 'url_change', url: fullUrl}, |
| {type: 'setUrl', url: fullUrl}, |
| ]; |
| var targets = []; |
| if (window.parent && window.parent !== window) targets.push(window.parent); |
| if (window.top && window.top !== window && window.top !== window.parent) targets.push(window.top); |
| for (var i = 0; i < msgs.length; i++) { |
| for (var j = 0; j < targets.length; j++) { |
| try { targets[j].postMessage(msgs[i], '*'); } catch(e) {} |
| } |
| } |
| } |
| |
| function _updateUrl(q, fields) { |
| if (!q) return; |
| var enc = encodeURIComponent(q); |
| var fStr = (fields || ['DEBTOR_NAME', 'CREDITOR_NAME']).join(','); |
| var u = new URL(location); |
| u.searchParams.set('q', enc); |
| u.searchParams.set('f', fStr); |
| history.replaceState({}, '', u.toString()); |
| _updateTitle(q, fields); |
| _notifyParent(u.pathname + u.search); |
| } |
| |
| /* ══════════════════════════════════════════════════════════ |
| * STATE MACHINE — enterLoading / exitLoading |
| * ══════════════════════════════════════════════════════════ */ |
| |
| function _stopAllTimers() { |
| if (_contentObserver) { _contentObserver.disconnect(); _contentObserver = null; } |
| if (_classObserver) { _classObserver.disconnect(); _classObserver = null; } |
| if (_safetyTimer) { clearTimeout(_safetyTimer); _safetyTimer = null; } |
| if (_elapsedTimer) { clearInterval(_elapsedTimer); _elapsedTimer = null; } |
| } |
| |
| /* |
| * enterLoading(q): IDLE → SEARCHING |
| * |
| * 1. Increment search ID for stale-detection |
| * 2. Lock input, show visual loading indicators |
| * 3. Find content wrapper and inject skeleton with unique marker |
| * 4. Start elapsed timer |
| * 5. Start MutationObserver (PRIMARY): watch for marker removal |
| * 6. Start MutationObserver (SECONDARY): watch for Gradio class removal |
| * 7. Start safety timeout (30s) |
| */ |
| function enterLoading(q) { |
| _stopAllTimers(); |
| |
| var myId = ++_searchId; |
| window._searchActive = true; |
| window._searchStartTime = _elapsedStart = Date.now(); |
| |
| /* Lock input, show loading UI */ |
| var ci = document.getElementById('search-input'); |
| var btn = document.getElementById('search-btn'); |
| var srow = ci ? ci.closest('.srow') : null; |
| var topLoader = document.getElementById('top-loader'); |
| |
| if (ci) { ci.readOnly = true; ci.classList.add('loading', 'readonly'); } |
| if (btn) { btn.classList.add('loading'); btn.textContent = '\\u041f\\u043e\\u0448\\u0443\\u043a ...'; } |
| if (srow) srow.classList.add('loading'); |
| if (topLoader) topLoader.classList.add('active'); |
| |
| /* Inject skeleton into content wrapper */ |
| var wrapper = _getContentWrapper(); |
| var markerId = '_ld-' + myId; |
| if (wrapper) { |
| wrapper.innerHTML = |
| '<div class="search-loader">' + |
| '<div id="' + markerId + '">' + |
| '<div class="loader-ring-wrap">' + |
| '<div class="loader-ring"></div>' + |
| '<div class="loader-ring-inner"></div>' + |
| '</div>' + |
| '<div class="loader-label">\\u041f\\u043e\\u0448\\u0443\\u043a . . .</div>' + |
| '<div class="loader-query-box">\\u00ab' + _escHtml(q) + '\\u00bb</div>' + |
| '<div class="loader-elapsed">0.0\\u0441</div>' + |
| '<div class="loader-hint">\\u0411\\u0443\\u0434\\u044c \\u043b\\u0430\\u0441\\u043a\\u0430, \\u0437\\u0430\\u0447\\u0435\\u043a\\u0430\\u0439\\u0442\\u0435</div>' + |
| '</div></div>'; |
| } |
| |
| /* Elapsed timer — updates the counter in the skeleton */ |
| _elapsedTimer = setInterval(function() { |
| var el = document.querySelector('.loader-elapsed'); |
| if (el && _elapsedStart) { |
| el.textContent = ((Date.now() - _elapsedStart) / 1000).toFixed(1) + '\\u0441'; |
| } |
| }, 100); |
| |
| /* |
| * PRIMARY DETECTION: MutationObserver on content wrapper. |
| * Watches for our unique marker being removed from the DOM. |
| * This happens when Svelte sets innerHTML on the wrapper with |
| * server response (results, error, or "nothing found" message). |
| * |
| * Started in next tick (setTimeout 5ms) to skip our own |
| * innerHTML injection which would immediately trigger it. |
| */ |
| setTimeout(function() { |
| if (!window._searchActive || myId !== _searchId) return; |
| if (!wrapper) return; |
| |
| /* Guard: only fire once per search */ |
| var _exited = false; |
| function _tryExit() { |
| if (_exited || !window._searchActive || myId !== _searchId) return; |
| _exited = true; |
| if (_contentObserver) { _contentObserver.disconnect(); _contentObserver = null; } |
| if (_classObserver) { _classObserver.disconnect(); _classObserver = null; } |
| requestAnimationFrame(function() { |
| if (myId === _searchId && window._searchActive) exitLoading(); |
| }); |
| } |
| |
| /* Observer 1: marker removal in content wrapper */ |
| _contentObserver = new MutationObserver(function() { |
| /* Check if our unique marker still exists anywhere in the wrapper */ |
| if (!wrapper.querySelector('#' + markerId)) { |
| _tryExit(); |
| } |
| }); |
| _contentObserver.observe(wrapper, { childList: true, subtree: true }); |
| |
| /* Observer 2: Gradio .is-generating/.pending class removal on #res */ |
| var resEl = document.getElementById('res'); |
| if (resEl) { |
| var _pendingWasAdded = false; |
| _classObserver = new MutationObserver(function() { |
| var cls = resEl.className || ''; |
| var hasPending = cls.indexOf('is-generating') >= 0 || cls.indexOf('pending') >= 0; |
| if (hasPending) { |
| _pendingWasAdded = true; |
| } else if (_pendingWasAdded) { |
| /* .is-generating was added then removed — server round-trip complete */ |
| _tryExit(); |
| } |
| }); |
| _classObserver.observe(resEl, { attributes: true, attributeFilter: ['class'] }); |
| } |
| }, 5); |
| |
| /* Safety timeout: unblock after 30s no matter what */ |
| _safetyTimer = setTimeout(function() { |
| if (myId === _searchId && window._searchActive) exitLoading(); |
| }, 30000); |
| } |
| |
| /* |
| * exitLoading(): SEARCHING → IDLE |
| * • Unlocks input, removes all loading visual indicators |
| * • Disconnects all observers, clears all timers |
| */ |
| function exitLoading() { |
| window._searchActive = false; |
| _stopAllTimers(); |
| |
| var ci = document.getElementById('search-input'); |
| var btn = document.getElementById('search-btn'); |
| var srow = ci ? ci.closest('.srow') : null; |
| var topLoader = document.getElementById('top-loader'); |
| |
| if (ci) { ci.readOnly = false; ci.classList.remove('loading', 'readonly'); } |
| if (btn) { btn.classList.remove('loading'); btn.textContent = '\\u0417\\u043d\\u0430\\u0439\\u0442\\u0438'; } |
| if (srow) srow.classList.remove('loading'); |
| if (topLoader) topLoader.classList.remove('active'); |
| } |
| |
| /* Expose for auto-retry */ |
| window._enterLoading = enterLoading; |
| window._exitLoading = exitLoading; |
| |
| /* ── Search event handler ── */ |
| function _initSearchHandlers() { |
| var customInput = document.getElementById('search-input'); |
| var customBtn = document.getElementById('search-btn'); |
| if (!customInput || !customBtn) return; |
| |
| customBtn.addEventListener('click', function(e) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| /* Guard: don't start a new search while one is in progress */ |
| if (window._searchActive) return; |
| |
| var q = customInput.value.trim(); |
| /* Client-side validation: block short/empty queries */ |
| if (!q || q.length < 3) return; |
| |
| /* Read currently active pills */ |
| var fields = []; |
| document.querySelectorAll('.fpill.active').forEach(function(p) { |
| fields.push(p.dataset.field); |
| }); |
| if (!fields.length) fields = ['DEBTOR_NAME', 'CREDITOR_NAME']; |
| |
| /* Sync state: pills → hidden Gradio textbox, URL, title */ |
| _syncFieldsToGradio(fields.join(',')); |
| _updateUrl(q, fields); |
| |
| /* Transition: IDLE → SEARCHING */ |
| enterLoading(q); |
| |
| /* Trigger Gradio search (hidden bridge) */ |
| _triggerGradioSearch(); |
| }); |
| |
| customInput.addEventListener('keydown', function(e) { |
| if (e.key === 'Enter') { |
| e.preventDefault(); |
| e.stopPropagation(); |
| customBtn.click(); |
| } |
| }); |
| } |
| |
| /* ── pill click handler (event delegation) ── */ |
| document.addEventListener('click', function(e) { |
| if (e.target.classList && e.target.classList.contains('fpill')) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| e.target.classList.toggle('active'); |
| /* No search triggered — user toggles pills, then clicks search */ |
| } |
| }); |
| |
| /* ── sync pills from URL ?f= param on load ── */ |
| function _syncPillsFromUrl() { |
| var fields = _readUrlFields(); |
| if (!fields || (fields.length === 2 && fields[0] === 'DEBTOR_NAME' && fields[1] === 'CREDITOR_NAME')) return; |
| document.querySelectorAll('.fpill').forEach(function(pill) { |
| if (fields.indexOf(pill.dataset.field) >= 0) { |
| pill.classList.add('active'); |
| } else { |
| pill.classList.remove('active'); |
| } |
| }); |
| } |
| |
| /* ── sync custom input from URL ?q= param on load ── */ |
| function _syncInputFromUrl() { |
| var q = _readUrlQuery(); |
| if (!q) return; |
| var customInput = document.getElementById('search-input'); |
| if (customInput) customInput.value = q; |
| } |
| |
| /* ── auto-retry: watch status bar for "ready" then re-trigger search ── */ |
| function _tryAutoSearch() { |
| if (_autoTriggered) return; |
| |
| var customInput = document.getElementById('search-input'); |
| var q = customInput ? (customInput.value || '').trim() : null; |
| if (!q || q.length < 3) return; |
| |
| /* Don't re-trigger if results are already shown */ |
| var res = document.getElementById('res'); |
| if (res && res.querySelector('.card')) return; |
| if (res && res.textContent.indexOf('\\u041d\\u0456\\u0447\\u043e\\u0433\\u043e') >= 0) return; |
| |
| _autoTriggered = true; |
| var observer = document.getElementById('status-bar'); |
| if (observer && observer._retryObserver) { observer._retryObserver.disconnect(); } |
| |
| var fields = []; |
| document.querySelectorAll('.fpill.active').forEach(function(p) { |
| fields.push(p.dataset.field); |
| }); |
| if (!fields.length) fields = ['DEBTOR_NAME', 'CREDITOR_NAME']; |
| _syncFieldsToGradio(fields.join(',')); |
| _updateUrl(q, fields); |
| |
| enterLoading(q); |
| _triggerGradioSearch(); |
| } |
| |
| function _isStatusReady() { |
| var statusBar = document.getElementById('status-bar'); |
| if (!statusBar) return false; |
| var text = statusBar.textContent || ''; |
| return text.indexOf('\\u0437\\u0430\\u043f\\u0438\\u0441\\u0456\\u0432') >= 0; |
| } |
| |
| function _setupAutoRetry() { |
| var statusBar = document.getElementById('status-bar'); |
| if (!statusBar) return; |
| |
| /* |
| * CRITICAL: check current status IMMEDIATELY. |
| * If DB is already ready when we set up the observer (e.g. server was |
| * warm or demo.load already returned "ready"), no future mutation will |
| * occur and the observer would never fire. By checking now we catch |
| * the "already ready" case. |
| */ |
| if (_isStatusReady()) { |
| setTimeout(_tryAutoSearch, 100); |
| } |
| |
| var observer = new MutationObserver(function() { |
| if (_autoTriggered) { observer.disconnect(); return; } |
| if (!_isStatusReady()) return; |
| /* Small delay to let Gradio finish rendering the status update */ |
| setTimeout(_tryAutoSearch, 200); |
| }); |
| |
| statusBar._retryObserver = observer; |
| observer.observe(statusBar, { childList: true, subtree: true, characterData: true }); |
| setTimeout(function() { observer.disconnect(); }, 300000); |
| } |
| |
| /* ── init ───────────────────────────────── */ |
| function init() { |
| _syncPillsFromUrl(); |
| _syncInputFromUrl(); |
| _initSearchHandlers(); |
| _setupAutoRetry(); |
| |
| /* Sync initial pill state to hidden Gradio textbox */ |
| var fields = []; |
| document.querySelectorAll('.fpill.active').forEach(function(p) { |
| fields.push(p.dataset.field); |
| }); |
| if (!fields.length) fields = ['DEBTOR_NAME', 'CREDITOR_NAME']; |
| _syncFieldsToGradio(fields.join(',')); |
| |
| /* |
| * If page loaded with ?q= params, show a cosmetic skeleton. |
| * This is purely visual — NO enterLoading() is called, so no |
| * observers or timers are started. The demo.load response will |
| * replace this skeleton naturally when it arrives. |
| * |
| * State remains IDLE throughout. |
| */ |
| var q = _readUrlQuery(); |
| if (q && q.length >= 3) { |
| var urlFields = _readUrlFields(); |
| _updateTitle(q, urlFields); |
| /* Also notify parent of URL on initial load */ |
| _notifyParent(location.pathname + location.search); |
| var wrapper = _getContentWrapper(); |
| if (wrapper) { |
| wrapper.innerHTML = |
| '<div class="search-loader">' + |
| '<div class="loader-ring-wrap">' + |
| '<div class="loader-ring"></div>' + |
| '<div class="loader-ring-inner"></div>' + |
| '</div>' + |
| '<div class="loader-label">\\u041f\\u043e\\u0448\\u0443\\u043a . . .</div>' + |
| '<div class="loader-query-box">\\u00ab' + _escHtml(q) + '\\u00bb</div>' + |
| '<div class="loader-sub">\\u0417\\u0430\\u0432\\u0430\\u043d\\u0442\\u0430\\u0436\\u0435\\u043d\\u043d\\u044f \\u0434\\u0430\\u043d\\u0438\\u0445</div>' + |
| '</div>'; |
| } |
| } |
| } |
| |
| /* |
| * Startup: wait for Gradio to render our custom HTML elements |
| * before running init(). On first private-tab load, Gradio JS bundles |
| * are not cached, so hydration can take several seconds. |
| * A fixed 300ms timeout is too fragile — we poll every 100ms for up |
| * to 15s until #search-input appears in the DOM. |
| */ |
| function _tryInit(attempts) { |
| if (typeof attempts === 'undefined') attempts = 150; |
| var el = document.querySelector('#search-input'); |
| if (el) { |
| init(); |
| return; |
| } |
| if (attempts > 0) { |
| setTimeout(function() { _tryInit(attempts - 1); }, 100); |
| } else { |
| /* Last resort: run init anyway, some features may be degraded */ |
| init(); |
| } |
| } |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', function() { setTimeout(function() { _tryInit(); }, 50); }); |
| } else { |
| _tryInit(); |
| } |
| })(); |
| </script> |
| """ |
|
|
| |
|
|
| with gr.Blocks(title=TITLE) as demo: |
|
|
| |
| status_bar = gr.HTML(value=get_status(), elem_id="status-bar") |
|
|
| |
| gr.HTML( |
| '<div class="hdr">' |
| '<h1>Реєстр боржників</h1>' |
| '<p>Пошук у Реєстрі виконавчих проваджень ASVP · ' |
| "Регістр №28 Мін\u2019юсту України</p></div>" |
| ) |
|
|
| |
| gr.HTML(value=_SEARCH_BAR_HTML) |
|
|
| |
| inp = gr.Textbox(elem_id="q-hidden", show_label=False, container=False) |
| fields_hidden = gr.Textbox(elem_id="fields-hidden", visible=False, value="DEBTOR_NAME,CREDITOR_NAME") |
| btn = gr.Button("search", elem_id="search-trigger") |
|
|
| |
| pills_out = gr.HTML(value=_render_field_pills(), elem_id="fields") |
|
|
| |
| out = gr.HTML(value=_welcome_html(), elem_id="res") |
|
|
| |
| if HAS_TIMER: |
| status_timer = gr.Timer(value=3.0, active=True) |
| status_timer.tick( |
| fn=_timer_tick, |
| outputs=[status_bar, status_timer], |
| ) |
|
|
| |
| btn.click( |
| fn=search_and_status, |
| inputs=[inp, fields_hidden], |
| outputs=[out, status_bar], |
| js=_SKELETON_JS, |
| ) |
|
|
| |
| demo.load( |
| fn=_on_load, |
| inputs=[inp, fields_hidden], |
| outputs=[pills_out, out, status_bar], |
| js=_JS_READ_URL, |
| ) |
|
|
|
|
| |
| demo.launch( |
| css=CSS, |
| head=_HEAD_JS, |
| theme=gr.themes.Default(), |
| ssr_mode=False, |
| ) |