#!/usr/bin/env python3
"""NiceGUI web UI that exposes the functions in tools/search_md.py.
Run this from the repository root (so paths like `docs_md/articles` resolve):
python tools/search_md_gui.py
The app presents a small form to run the same searches as `tools/search_md.py` and
renders results as a markdown table with links to local files and (if docid exists)
an example link to the app.statdx document URL.
"""
from __future__ import annotations
import os
import json
import importlib.util
from typing import List, Dict, Any
import anyio
import asyncio
from nicegui import ui, app
import re
_current_dir = os.path.dirname(os.path.abspath(__file__))
_articles_dir = os.path.abspath(os.path.join(_current_dir, '..', 'docs_md', 'articles'))
_images_dir = os.path.join(_articles_dir, 'images')
_img_dir = os.path.join(_articles_dir, 'img')
app.add_static_files('/images', _images_dir)
app.add_static_files('/document/images', _images_dir)
app.add_static_files('/img', _img_dir)
app.add_static_files('/document/img', _img_dir)
import shutil
import subprocess
def load_search_md_module() -> object:
"""Load tools/search_md.py as a module without requiring tools/ to be a package."""
path = os.path.join(os.path.dirname(__file__), 'search_md.py')
spec = importlib.util.spec_from_file_location('search_md', path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module) # type: ignore[attr-defined]
return module
search_md = load_search_md_module()
def load_document_to_markdown_module() -> object:
"""Load scrapers/document_to_markdown.py as a module without requiring scrapers/ to be a package."""
path = os.path.join(os.path.dirname(__file__), '..', 'scrapers', 'document_to_markdown.py')
path = os.path.abspath(path)
spec = importlib.util.spec_from_file_location('document_to_markdown', path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module) # type: ignore[attr-defined]
return module
document_to_markdown = load_document_to_markdown_module()
@ui.page('/')
def search_page() -> None: # build UI
# Load fonts & modern dark mode stylesheet
ui.add_head_html('''
''')
with ui.column().classes('w-full max-w-7xl mx-auto p-6 gap-6'):
# Header / Title Block
with ui.row().classes('items-center gap-4 w-full justify-between pb-4 border-b border-slate-800'):
with ui.column().classes('gap-1'):
ui.html('
STATdx Article Explorer
')
ui.label('Search extracted markdown files with fuzzy matching, stemming, and interactive selection.').classes('text-slate-400 text-base')
with ui.row().classes('gap-2'):
ui.button('Open Output Dir', icon='folder', on_click=lambda: open_output_dir(), color='secondary').props('outline')
# Main Search card
queries = [
{
'qval': '',
'mode': 'exact',
'targets': ['Title', 'Content', 'Breadcrumbs', 'Keywords', 'Category', 'Type']
}
]
with ui.card().classes('w-full p-6 shadow-xl gap-4'):
query_blocks_container = ui.column().classes('w-full gap-4')
def render_query_blocks():
query_blocks_container.clear()
with query_blocks_container:
for idx, q in enumerate(queries):
with ui.row().classes('w-full items-center gap-4 border border-slate-800 p-4 rounded-lg bg-slate-900/40 relative'):
label_text = "Query 1 (Primary)" if idx == 0 else f"OR Query {idx + 1}"
ui.label(label_text).classes('text-blue-400 font-semibold w-36')
# Bind value and update on change
qval_input = ui.input(
label='Search Query',
placeholder='Type search terms...',
value=q['qval'],
on_change=lambda e, q=q: q.update({'qval': e.value})
).classes('flex-grow').props('outlined dense').on('keydown.enter', lambda: asyncio.create_task(run_search_handler()))
mode_select = ui.select(
options={'exact': 'Exact Match', 'fuzzy': 'Fuzzy Match', 'stemming': 'Stemming (Related Words)'},
value=q['mode'],
label='Match Mode',
on_change=lambda e, q=q: q.update({'mode': e.value})
).classes('w-48').props('outlined dense')
targets_select = ui.select(
options={
'Title': 'Title',
'Content': 'Content',
'Breadcrumbs': 'Breadcrumbs',
'Authors': 'Authors',
'Keywords': 'Keywords',
'Category': 'Category',
'Type': 'Type'
},
value=q['targets'],
label='Search Targets',
multiple=True,
on_change=lambda e, q=q: q.update({'targets': e.value})
).classes('w-72').props('outlined dense use-chips')
if idx > 0:
ui.button(
icon='delete',
on_click=lambda _, idx=idx: remove_query_block(idx)
).classes('text-red-400 hover:text-red-500').props('flat round dense')
def add_query_block():
queries.append({
'qval': '',
'mode': 'exact',
'targets': ['Title', 'Content', 'Breadcrumbs', 'Keywords', 'Category', 'Type']
})
render_query_blocks()
def remove_query_block(idx: int):
if 0 < idx < len(queries):
queries.pop(idx)
render_query_blocks()
# Render initial query blocks
render_query_blocks()
# Persistent Search History Container
with ui.row().classes('w-full items-center gap-2 mt-2'):
ui.label('Recent Searches:').classes('text-slate-400 text-xs font-semibold')
history_container = ui.row().classes('gap-1 items-center flex-wrap')
clear_history_container = ui.row().classes('items-center')
def render_history_chips():
history_container.clear()
clear_history_container.clear()
history = load_history()
if not history:
with history_container:
ui.label('No history yet').classes('text-slate-500 text-xs italic')
return
with history_container:
for idx, item in enumerate(history[:10]):
def make_click(saved=item['queries']):
def handler():
nonlocal queries
queries.clear()
for sq in saved:
queries.append(json.loads(json.dumps(sq)))
render_query_blocks()
asyncio.create_task(run_search_handler())
return handler
def make_delete(label=item['label']):
def handler(e):
# value becomes False when chip is removed via X
if not e.value:
h = load_history()
h = [x for x in h if x['label'] != label]
save_history(h)
render_history_chips()
ui.notify('Removed from history', type='info')
return handler
ui.chip(
item['label'],
removable=True,
on_click=make_click(),
on_value_change=make_delete(),
).props('dense color="slate-800" text-color="blue-300" icon="history"').classes('text-xs hover:bg-slate-700 cursor-pointer')
with clear_history_container:
if history:
def clear_all_history():
save_history([])
render_history_chips()
ui.notify('Search history cleared', type='info')
ui.button(icon='delete_sweep', on_click=clear_all_history).props('flat round dense size="xs"').classes('text-slate-500 hover:text-red-400').tooltip('Clear all history')
render_history_chips()
with ui.row().classes('w-full justify-between items-center gap-4 mt-2'):
with ui.row().classes('items-center gap-4'):
ui.button('Add OR Query', icon='add', on_click=add_query_block).classes('text-blue-400 border border-blue-800').props('outline dense')
expand_chk = ui.checkbox('Expand with Linked Articles', value=False).classes('text-slate-300')
search_btn = ui.button('Search', icon='search', on_click=lambda: asyncio.create_task(run_search_handler())).classes('bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-8 rounded-lg shadow-md transition-all duration-300')
# Advanced Criteria Expansion Box
with ui.expansion('Advanced Configurations', icon='settings').classes('w-full text-slate-300 border border-slate-800 rounded-lg p-2'):
with ui.row().classes('w-full gap-4 items-start p-4'):
root_input = ui.input(value='docs_md/articles', label='Root directory').classes('w-64').props('outlined dense')
key_select = ui.select(['global', 'breadcrumbs', 'authors', 'pageKeywords', 'category', 'title', 'enhancedTitle', 'type', 'content'], value='global', label='Query key').classes('w-64').props('outlined dense')
fmt_select = ui.select(['json', 'csv', 'combined_json', 'combined_md'], value='json', label='Output format').classes('w-40').props('outlined dense')
out_input = ui.input(value='results.json', label='Output file (server-side)').classes('w-64').props('outlined dense')
copy_input = ui.input(value='out', label='Copy matched files to (optional)').classes('w-64').props('outlined dense')
copy_clear = ui.checkbox('Clear destination before copy', value=False).classes('self-center text-slate-300')
status = ui.label('').classes('text-blue-400 font-semibold text-lg px-2')
# Action bar for selected rows
# Hidden by default, displayed via bind_visibility_from
action_bar = ui.row().classes('w-full justify-between items-center gap-4 bg-slate-800 p-4 rounded-lg border border-slate-700')
action_bar_text = ui.label('').classes('text-white font-semibold')
with action_bar:
with ui.row().classes('gap-2'):
ui.button('Copy Selected Files', icon='content_copy', on_click=lambda: asyncio.create_task(copy_selected())).classes('bg-indigo-600 hover:bg-indigo-700 text-white font-medium px-4')
ui.button('Export Combined JSON', icon='library_books', on_click=lambda: asyncio.create_task(export_combined_selected())).classes('bg-teal-600 hover:bg-teal-700 text-white font-medium px-4')
ui.button('Export Combined MD', icon='article', on_click=lambda: asyncio.create_task(export_combined_md())).classes('bg-cyan-600 hover:bg-cyan-700 text-white font-medium px-4')
ui.button('Export Selected', icon='file_download', on_click=lambda: asyncio.create_task(export_selected())).classes('bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4')
ui.button('Deselect All', icon='close', on_click=lambda: table.selected.clear()).classes('bg-slate-700 hover:bg-slate-600 text-white font-medium px-4')
# Results table filter input
filter_card = ui.row().classes('w-full items-center gap-4')
with filter_card:
table_filter = ui.input(
placeholder='Filter results table below (live)...'
).classes('w-full').props('outlined dense clearable')
table_filter.add_slot('append', '')
# Table definition
columns = [
{'name': 'title', 'label': 'Title', 'field': 'title', 'required': True, 'align': 'left', 'sortable': True},
{'name': 'reasons', 'label': 'Matched In', 'field': 'reasons', 'align': 'left', 'sortable': True},
{'name': 'anatomy', 'label': 'Anatomy', 'field': 'anatomy', 'align': 'center'},
{'name': 'differential', 'label': 'Diff Diagnosis', 'field': 'differential', 'align': 'center'},
{'name': 'docid', 'label': 'DocID', 'field': 'docid', 'align': 'left'},
{'name': 'path', 'label': 'Path', 'field': 'path', 'align': 'left', 'sortable': True},
]
table = ui.table(
columns=columns,
rows=[],
row_key='path',
selection='multiple',
).classes('w-full shadow-lg')
table.bind_filter_from(table_filter, 'value')
# Bind action bar visibility and selection text
action_bar.bind_visibility_from(table, 'selected', lambda sel: len(sel) > 0)
action_bar_text.bind_text_from(table, 'selected', lambda sel: f'{len(sel)} files selected')
# Add slot overrides to format table cells nicely
table.add_slot('body-cell-title', '''