From 99e355e348468e8526f8bb207a74a874d1f7b764 Mon Sep 17 00:00:00 2001 From: Sergey Karpov Date: Mon, 11 Dec 2023 15:22:36 +0100 Subject: [PATCH 1/6] Local changes for smartsearch --- magicbar/assets/test4.css | 13 ++ magicbar/parse.py | 275 ++++++++++++++++++++++++++++++++++++++ magicbar/smartsearch.py | 229 +++++++++++++++++++++++++++++++ magicbar/telemetry.py | 78 +++++++++++ 4 files changed, 595 insertions(+) create mode 100644 magicbar/assets/test4.css create mode 100644 magicbar/parse.py create mode 100644 magicbar/smartsearch.py create mode 100644 magicbar/telemetry.py diff --git a/magicbar/assets/test4.css b/magicbar/assets/test4.css new file mode 100644 index 00000000..dfa4f829 --- /dev/null +++ b/magicbar/assets/test4.css @@ -0,0 +1,13 @@ +.inputbar:focus { + outline: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +#magic_suggest .list-group-item { + background: rgba(248, 248, 248, 1); +} + +ul.react-autocomplete-input { + /* position: absolute; */ +} diff --git a/magicbar/parse.py b/magicbar/parse.py new file mode 100644 index 00000000..87c00ecf --- /dev/null +++ b/magicbar/parse.py @@ -0,0 +1,275 @@ +import shlex +import regex as re # For partial matching +import requests +import functools + +APIURL = 'https://fink-portal.org' + +@functools.lru_cache(maxsize=320) +def call_resolver(data, kind, timeout=None): + """ Call Fink resolver + + Parameters + ---------- + data: str + Query payload + kind: str + Resolver name: ssodnet, tns, simbad + + Returns + ---------- + payload: dict + Payload returned by the /api/v1/resolver endpoint + """ + + if kind == 'tns': + # Normalize AT name to have whitespace before year + m = re.match('^(AT)\s*(2\w+)$', data) + if m: + data = m[1] + ' ' + m[2] + + try: + if kind == 'ztf': + r = requests.post( + '{}/api/v1/objects'.format(APIURL), + json={ + 'objectId': data, + 'columns': "i:ra,i:dec", + }, + timeout=timeout + ) + else: + r = requests.post( + '{}/api/v1/resolver'.format(APIURL), + json={ + 'resolver': kind, + 'name': str(data), + }, + timeout=timeout + ) + + payload = r.json() + except requests.exceptions.ReadTimeout: + payload = None + + return payload + +name_patterns = [ + { + 'type': 'ztf', + 'pattern': '^ZTF[12]\d\w{7}$', + 'hint': 'ZTF objectId: ZTFyyccccccc', + 'min': 3 + }, + { + 'type': 'tracklet', + 'pattern': '^TRCK_\d{8}_\d{6}_\d{2}$', + 'hint': 'tracklet: TRCK_YYYYMMDD_HHMMSS_NN', + 'min': 4 + }, + # { + # 'type': 'at', + # 'pattern': '^AT[12]\d{3}\w{3}$', + # 'hint': 'AT: ATyyyyccc', + # 'min': 3 + # }, +] + +def parse_query(string, timeout=None): + """ Parse (probably incomplete) query + + Order is as follows: + 1. Extract object names (partially) matching some patterns + 2. Extract keyword parameters, either key:value or key=value + 3. Try to interpret the rest as coordinates as follows: + - Pair of degrees + - HH MM SS.S [+-]?DD MM SS.S + - HH:MM:SS.S [+-]?DD:MM:SS.S + - HHhMMhSS.Ss [+-]?DDhMMhSS.Ss + - optionally, use one more number as a radius, in either arcseconds, minutes or degrees + 4. The rest is resolved through several Fink resolvers + 5. Finally, the action is suggested based on the parameters + - for ZTF objectIds it is 'objectid' unless the radius `r` is explicitly given and the match is not partial (then it is 'conesearch') + - for tracklets, it is always 'tracklet' + - for resolved objects or coordinates, it is `conesearch` even if no radius is specified + - for SSO objects it is always `sso` + + Parameters + ---------- + string: str + String to parse + + Returns + ---------- + Dictionary containing the following keys: + - object: object name + - type: object type derived from name parsing + - hint: some human-readable description of what was parsed + - action: suggested action for the query + - params: dictionary with keyword parameters (ra, dec, r, ...) + - string: original query string + """ + # Results schema + query = { + 'object': None, + 'type': None, + 'partial': False, + 'hint': None, + 'action': None, + 'params': {}, + 'string': string, + } + + string = string.replace(',', ' ') # TODO: preserve quoted commas?.. + + try: + tokens = shlex.split(string, posix=True) # It will also handle quoted strings + except: + return query + + unparsed = [] + + for token in tokens: + is_parsed = False + # Try to locate well-defined object name patterns + for pattern in name_patterns: + if pattern.get('min') and len(token) >= pattern.get('min'): + m = re.match(pattern['pattern'], token, partial=True) + if m: + query['object'] = token + query['type'] = pattern['type'] + query['hint'] = pattern['hint'] + query['partial'] = m.partial + is_parsed = True + break + + if is_parsed: + continue + + # Try to parse keyword parameters, either as key:value or key=value + m = re.match('^(\w+)[=:]([^:=]*?)$', token) + if m: + key = m[1] + value = m[2] + # Special handling for numbers, possibly ending with d/m/s for degrees etc + m = re.match('^([+-]?(\d+)(.\d+)?)([dms\'"]?)$', value) + if m: + value = float(m[1]) + if m[4] == 'd': + value /= 1 + elif m[4] == 'm' or m[4] == '\'': + value /= 60 + elif m[4] == 's' or m[4] == '"': + value /= 3600 + else: + # Default is no change, except for 'r' key + if key == 'r': + value /= 3600 + + query['params'][key] = value + + else: + unparsed.append(token) + + string = " ".join(unparsed) + + # Parse the rest of the query string as coordinates, if any + if len(string) and not query['object']: + # Pair of decimal degrees + m = re.search("^(\d+\.?\d*)\s+([+-]?\d+\.?\d*)(\s+(\d+\.?\d*))?$", string) + if m: + query['params']['ra'] = float(m[1]) + query['params']['dec'] = float(m[2]) + if m[4] is not None: + query['params']['r'] = float(m[4])/3600 + + query['object'] = string + query['type'] = 'coordinates' + query['hint'] = 'Decimal coordinates' + + if m[4] is not None: + query['hint'] += ' with radius' + + else: + # HMS DMS + m = re.search( + "^(\d{1,2})\s+(\d{1,2})\s+(\d{1,2}\.?\d*)\s+([+-])?\s*(\d{1,3})\s+(\d{1,2})\s+(\d{1,2}\.?\d*)(\s+(\d+\.?\d*))?$", + string + ) or re.search( + "^(\d{1,2})[:h](\d{1,2})[:m](\d{1,2}\.?\d*)[s]?\s+([+-])?\s*(\d{1,3})[d:](\d{1,2})[m:](\d{1,2}\.?\d*)[s]?(\s+(\d+\.?\d*))?$", + string + ) + if m: + query['params']['ra'] = (float(m[1]) + float(m[2])/60 + float(m[3])/3600)*15 + query['params']['dec'] = (float(m[5]) + float(m[6])/60 + float(m[7])/3600) + + if m[4] == '-': + query['params']['dec'] *= -1 + + if m[9] is not None: + query['params']['r'] = float(m[9])/3600 + + query['object'] = string + query['type'] = 'coordinates' + query['hint'] = 'HMS DMS coordinates' + + if m[9] is not None: + query['hint'] += ' with radius' + + else: + query['object'] = string + query['type'] = 'unresolved' + + # Should we resolve object name?.. + if query['object'] and query['type'] == 'ztf' and not query['partial'] and 'r' in query['params']: + res = call_resolver(query['object'], 'ztf') + if res: + query['params']['ra'] = res[0]['i:ra'] + query['params']['dec'] = res[0]['i:dec'] + + if query['object'] and query['type'] not in ['ztf', 'tracklet', 'coordinates', None]: + # Simbad + if query['object'][0].isalpha(): + res = call_resolver(query['object'], 'simbad', timeout=timeout) + if res: + query['object'] = res[0]['oname'] + query['type'] = 'simbad' + query['hint'] = 'Simbad object' + query['params']['ra'] = res[0]['jradeg'] + query['params']['dec'] = res[0]['jdedeg'] + + if 'ra' not in query['params'] and query['object'][0].isalpha(): + # TNS + res = call_resolver(query['object'], 'tns', timeout=timeout) + if res: + query['object'] = res[0]['d:fullname'] + query['type'] = 'tns' + query['hint'] = 'TNS object' + query['params']['ra'] = res[0]['d:ra'] + query['params']['dec'] = res[0]['d:declination'] + + if 'ra' not in query['params']: + # SSO - final test + res = call_resolver(query['object'], 'ssodnet', timeout=timeout) + if res: + query['object'] = res[0]['name'] + query['type'] = 'ssodnet' + query['hint'] = 'SSO object / {}'.format(res[0]['type']) + + # Guess the kind of query + if 'ra' in query['params'] and 'dec' in query['params']: + query['action'] = 'conesearch' + + elif query['type'] == 'ztf': + query['action'] = 'objectid' + + elif query['type'] == 'tracklet': + query['action'] = 'tracklet' + + elif query['type'] == 'ssodnet': + query['action'] = 'sso' + + else: + query['action'] = 'unknown' + + return query diff --git a/magicbar/smartsearch.py b/magicbar/smartsearch.py new file mode 100644 index 00000000..6a393778 --- /dev/null +++ b/magicbar/smartsearch.py @@ -0,0 +1,229 @@ +import dash +from dash import html, dcc, Input, Output, State, dash_table, no_update, ctx, clientside_callback +import requests +import dash_bootstrap_components as dbc +from dash.exceptions import PreventUpdate +import dash_mantine_components as dmc +from dash_iconify import DashIconify +import json + +from dash_autocomplete_input import AutocompleteInput + +from fink_utils.xmatch.simbad import get_simbad_labels +import pandas as pd + +simbad_types = get_simbad_labels('old_and_new') +simbad_types = sorted(simbad_types, key=lambda s: s.lower()) + +tns_types = pd.read_csv('../assets/tns_types.csv', header=None)[0].values +tns_types = sorted(tns_types, key=lambda s: s.lower()) + +finkclasses = [ + 'Unknown', + 'Early Supernova Ia candidates', + 'Supernova candidates', + 'Kilonova candidates', + 'Microlensing candidates', + 'Solar System (MPC)', + 'Solar System (candidates)', + 'Tracklet (space debris & satellite glints)', + 'Ambiguous', + *['(TNS) ' + t for t in tns_types], + *['(SIMBAD) ' + t for t in simbad_types] +] + +# bootstrap theme +external_stylesheets = [ + dbc.themes.SPACELAB, + '//aladin.u-strasbg.fr/AladinLite/api/v2/latest/aladin.min.css', + '//use.fontawesome.com/releases/v5.7.2/css/all.css', +] +external_scripts = [ + '//code.jquery.com/jquery-1.12.1.min.js', + '//aladin.u-strasbg.fr/AladinLite/api/v2/latest/aladin.min.js', + '//cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.4/MathJax.js?config=TeX-MML-AM_CHTML', +] + +from telemetry import DashWithTelemetry +app = DashWithTelemetry( +# app = dash.Dash( + __name__, + external_stylesheets=external_stylesheets, + external_scripts=external_scripts, + meta_tags=[{ + "name": "viewport", + "content": "width=device-width, initial-scale=1" + }] +) + +#################################### +# Smart search bar + +fink_search_bar = dbc.Row(dbc.Col( + className='p-0 m-0 border border-dark rounded-3', + children=[ + dbc.InputGroup( + [ + # dcc.Input( + # id='magic_search', + # value='', + # autoFocus=True, + # type='text', + # className='inputbar form-control border-0', + # ), + AutocompleteInput( + id='magic_search', + component='input', + trigger=['class:'], + # options={'class:':['class1', 'new class', 'next class', "type of all things"]}, + options={'class:':finkclasses}, + maxOptions=0, + className="inputbar form-control border-0", + quoteWhitespaces=True, + regex='^([a-zA-Z0-9_\-()]+|"[a-zA-Z0-9_\- ]*|\'[a-zA-Z0-9_\- ]*)$', + autoFocus=True, + ), + + dbc.Spinner([ + dmc.ActionIcon( + DashIconify(icon="tabler:search", width=20), + n_clicks=0, + id="submit", + color='gray', + variant="transparent", + radius='xl', + size='lg', + loaderProps={'variant': 'dots', 'color': 'orange'}, + # Hide on screen sizes smaller than sm + className="d-none d-sm-flex" + ), + ], size='sm',), + ], + ), + dbc.ListGroup( + id='magic_suggest', + ), + dcc.Interval(id="magic_timer", interval=2000, max_intervals=1, disabled=True) + ] +)) + +from parse import parse_query + +# Time-based debounce from https://joetatusko.com/2023/07/11/time-based-debouncing-with-plotly-dash/ +# @app.callback( +# Output('magic_timer', 'n_intervals'), +# Output('magic_timer', 'disabled'), +# Input('magic_search', 'value'), +# Input('magic_search', 'n_submit'), +# prevent_initial_call=True, +# ) +# def start_suggestion_debounce_timer(value, n_submit): +# if ctx.triggered[0]['prop_id'].split('.')[1] == 'n_submit': +# # Stop timer on submit +# return no_update, True +# else: +# # Start timer on normal input +# return 0, False + +clientside_callback( + """ + function start_suggestion_debounce_timer(value, n_submit) { + const triggered = dash_clientside.callback_context.triggered.map(t => t.prop_id); + if (triggered == 'magic_search.n_submit') + return [dash_clientside.no_update, true]; + return [0, false]; + } + """, + [ + Output('magic_timer', 'n_intervals'), + Output('magic_timer', 'disabled') + ], + Input('magic_search', 'value'), + Input('magic_search', 'n_submit'), + prevent_initial_call=True, +) + + +# Update suggestions +@app.callback( + Output('magic_suggest', 'children'), + Output('submit', 'children'), + Input('magic_timer', 'n_intervals'), + State('magic_search', 'value'), + prevent_initial_call=True, +) +def update_suggestions(n_intervals, value): + if n_intervals == 1: + if not value: + return None, no_update + query = parse_query(value, timeout=5) + suggestions = [] + + params = query['params'] + + if not query['action']: + return None, no_update + + content = [ + dmc.Group([ + html.Strong(query['object']) if query['object'] else None, + dmc.Badge(query['type'], variant="outline", color='blue') if query['type'] else None, + dmc.Badge(query['action'], variant="outline", color='red'), + ], noWrap=False, position='left'), + html.P(query['hint'], className='m-0'), + ] + + if len(params): + content.append( + html.Small(" ".join(["{}={}".format(_,params[_]) for _ in params])) + ) + + suggestion = dbc.ListGroupItem( + content, + action=True, + className='border-0' + ) + + suggestions.append(suggestion) + + return suggestions, no_update + else: + return no_update, no_update + +# Submit the results +@app.callback( + Output('check', 'children'), + Input('magic_search', 'n_submit'), + State('magic_search', 'value'), +) +def on_submit(n_submit, value): + if not n_submit: + raise PreventUpdate + else: + print('query:', value) + + if value: + query = parse_query(value) + return html.Span(str(query), className='text-danger') + else: + return no_update + +############################################# + +# Main app +app.layout = html.Div( + [ + dbc.Container( + [ + html.Br(), + dbc.Row(dbc.Col(fink_search_bar, width=12)), + html.Div(id='check'), + dmc.Space(h=20), + html.Pre(parse_query.__doc__), + ] + ) + ] +) + +# app.run_server('159.69.107.239', debug=True) +app.run_server(debug=True) diff --git a/magicbar/telemetry.py b/magicbar/telemetry.py new file mode 100644 index 00000000..88ef18b1 --- /dev/null +++ b/magicbar/telemetry.py @@ -0,0 +1,78 @@ +""" callback_telemetry.py -- Extend Dash to include logging of callbacks w/ context """ + +import inspect +import time +from functools import wraps + +from dash import Dash, callback_context + +from colorama import Fore, Back, Style + +LOG_CALLBACK_TELEMETRY = True + +# Borrowed from https://community.plotly.com/t/log-every-dash-callback-including-context-for-debug/74828/5 + +def callback_telemetry(func): + """wrapper to provide telemetry for dash callbacks""" + + @wraps(func) + def timeit_wrapper(*args, **kwargs): + def get_callback_ref(func_ref): + module = inspect.getmodule(func_ref) + return f"{module.__name__.split('.')[-1]}:{func_ref.__name__}" + + def flatten(arg): + if not isinstance(arg, list): # if not list + return [arg] + return [x for sub in arg for x in flatten(sub)] + + def generate_results_dict(function_output, outputs_list): + if isinstance(function_output, tuple): + output_strs = [ + f"{output}.{output['property']}" for output in flatten(outputs_list) + ] + return dict(zip(output_strs, flatten(function_output))) + return {f"{outputs_list['id']}.{outputs_list['property']}": function_output} + + def format_callback_dict(data): + return "||".join([f"{key}:{str(data[key])[:20]}" for key in data]) + + start_time = time.perf_counter() + result = func(*args, **kwargs) + end_time = time.perf_counter() + total_time = end_time - start_time + + results_dict = generate_results_dict(result, callback_context.outputs_list) + + inputs_str = format_callback_dict(callback_context.inputs) + state_str = format_callback_dict(callback_context.states) + result_str = format_callback_dict(results_dict) + + context = ( + f"___input:|{inputs_str}|\n___state:|{state_str}|\n__output:|{result_str}|" + ) + + print(f"{Fore.BLUE}[TELEMETRY]{Style.RESET_ALL} {Style.BRIGHT}{Fore.RED}{get_callback_ref(func)}{Style.RESET_ALL}, {total_time:.4f}s\n{context}") + + return result + + return timeit_wrapper + + +class DashWithTelemetry(Dash): + """Provide logging telemetry for Dash callbacks""" + + def callback(self, *_args, **_kwargs): + def decorator(function): + def wrapper(*args, **kwargs): + if LOG_CALLBACK_TELEMETRY: + retval = (callback_telemetry)(function)(*args, **kwargs) + else: + retval = function(*args, **kwargs) + + return retval + + fn = super(DashWithTelemetry, self).callback(*_args, **_kwargs) + return (fn)(wrapper) + + return decorator From b8e6665933818551519cbd4ff350ce38eabca382 Mon Sep 17 00:00:00 2001 From: Sergey Karpov Date: Mon, 11 Dec 2023 17:21:55 +0100 Subject: [PATCH 2/6] Prevent unnecessary calls to update_suggestions --- magicbar/smartsearch.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/magicbar/smartsearch.py b/magicbar/smartsearch.py index 6a393778..e40ed466 100644 --- a/magicbar/smartsearch.py +++ b/magicbar/smartsearch.py @@ -127,11 +127,15 @@ clientside_callback( """ - function start_suggestion_debounce_timer(value, n_submit) { + function start_suggestion_debounce_timer(value, n_submit, n_intervals) { const triggered = dash_clientside.callback_context.triggered.map(t => t.prop_id); if (triggered == 'magic_search.n_submit') return [dash_clientside.no_update, true]; - return [0, false]; + + if (n_intervals > 0) + return [0, false]; + else + return [dash_clientside.no_update, false]; } """, [ @@ -140,6 +144,7 @@ ], Input('magic_search', 'value'), Input('magic_search', 'n_submit'), + State('magic_timer', 'n_intervals'), prevent_initial_call=True, ) From 8bf01691151167511e7e56daab9ae12d92a70608 Mon Sep 17 00:00:00 2001 From: Sergey Karpov Date: Mon, 11 Dec 2023 23:43:59 +0100 Subject: [PATCH 3/6] Make autocomplete list scrollable --- magicbar/assets/test4.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/magicbar/assets/test4.css b/magicbar/assets/test4.css index dfa4f829..85e04cbc 100644 --- a/magicbar/assets/test4.css +++ b/magicbar/assets/test4.css @@ -9,5 +9,7 @@ } ul.react-autocomplete-input { - /* position: absolute; */ + overflow: hidden; + overflow-y: scroll; + max-height: 90vh; } From 1781e2da64b1c6ad7918f0a6146975b6d07a4150 Mon Sep 17 00:00:00 2001 From: Sergey Karpov Date: Tue, 12 Dec 2023 02:01:04 +0100 Subject: [PATCH 4/6] Recognize class-based query type as the one with 'class' keyword set --- magicbar/parse.py | 11 +++++++++-- magicbar/smartsearch.py | 9 ++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/magicbar/parse.py b/magicbar/parse.py index 87c00ecf..9ff31f50 100644 --- a/magicbar/parse.py +++ b/magicbar/parse.py @@ -58,13 +58,13 @@ def call_resolver(data, kind, timeout=None): { 'type': 'ztf', 'pattern': '^ZTF[12]\d\w{7}$', - 'hint': 'ZTF objectId: ZTFyyccccccc', + 'hint': 'ZTF objectId (format ZTFyyccccccc)', 'min': 3 }, { 'type': 'tracklet', 'pattern': '^TRCK_\d{8}_\d{6}_\d{2}$', - 'hint': 'tracklet: TRCK_YYYYMMDD_HHMMSS_NN', + 'hint': 'tracklet (format TRCK_YYYYMMDD_HHMMSS_NN)', 'min': 4 }, # { @@ -141,6 +141,9 @@ def parse_query(string, timeout=None): query['hint'] = pattern['hint'] query['partial'] = m.partial is_parsed = True + + if m.partial: + query['hint'] = query['hint'] + ' (partial)' break if is_parsed: @@ -269,6 +272,10 @@ def parse_query(string, timeout=None): elif query['type'] == 'ssodnet': query['action'] = 'sso' + elif 'class' in query['params']: + query['action'] = 'class' + query['hint'] = 'Class based search' + else: query['action'] = 'unknown' diff --git a/magicbar/smartsearch.py b/magicbar/smartsearch.py index e40ed466..f2217a5c 100644 --- a/magicbar/smartsearch.py +++ b/magicbar/smartsearch.py @@ -74,9 +74,12 @@ AutocompleteInput( id='magic_search', component='input', - trigger=['class:'], - # options={'class:':['class1', 'new class', 'next class', "type of all things"]}, - options={'class:':finkclasses}, + trigger=[ + 'class:', 'class=', + ], + options={ + 'class:':finkclasses, 'class=':finkclasses, + }, maxOptions=0, className="inputbar form-control border-0", quoteWhitespaces=True, From f10d536e7304cbbfc549b8e536703e25e447122b Mon Sep 17 00:00:00 2001 From: Sergey Karpov Date: Tue, 12 Dec 2023 17:00:03 +0100 Subject: [PATCH 5/6] Sorta autocompletion (not really, just 'did you mean?..') added --- magicbar/parse.py | 44 +++++++++++++++++++++++++++++------------ magicbar/smartsearch.py | 44 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/magicbar/parse.py b/magicbar/parse.py index 9ff31f50..654e58a7 100644 --- a/magicbar/parse.py +++ b/magicbar/parse.py @@ -3,10 +3,12 @@ import requests import functools +import numpy as np + APIURL = 'https://fink-portal.org' @functools.lru_cache(maxsize=320) -def call_resolver(data, kind, timeout=None): +def call_resolver(data, kind, timeout=None, reverse=False): """ Call Fink resolver Parameters @@ -44,6 +46,7 @@ def call_resolver(data, kind, timeout=None): json={ 'resolver': kind, 'name': str(data), + 'reverse': reverse, }, timeout=timeout ) @@ -117,6 +120,7 @@ def parse_query(string, timeout=None): 'hint': None, 'action': None, 'params': {}, + 'completions': [], 'string': string, } @@ -231,8 +235,29 @@ def parse_query(string, timeout=None): query['params']['dec'] = res[0]['i:dec'] if query['object'] and query['type'] not in ['ztf', 'tracklet', 'coordinates', None]: - # Simbad - if query['object'][0].isalpha(): + for reverse in [False, True]: + if 'ra' not in query['params'] and query['object'][0].isalpha(): + # TNS + res = call_resolver(query['object'], 'tns', timeout=timeout, reverse=reverse) + if res: + query['object'] = res[0]['d:fullname'] + query['type'] = 'tns' + query['hint'] = 'TNS object' + query['params']['ra'] = res[0]['d:ra'] + query['params']['dec'] = res[0]['d:declination'] + + if len(res) > 1: + # Make list of unique names not equal to the first one + query['completions'] = list( + np.unique( + [_['d:fullname'] for _ in res if _['d:fullname'] != res[0]['d:fullname']] + ) + ) + + break + + if 'ra' not in query['params'] and query['object'][0].isalpha(): + # Simbad res = call_resolver(query['object'], 'simbad', timeout=timeout) if res: query['object'] = res[0]['oname'] @@ -241,16 +266,6 @@ def parse_query(string, timeout=None): query['params']['ra'] = res[0]['jradeg'] query['params']['dec'] = res[0]['jdedeg'] - if 'ra' not in query['params'] and query['object'][0].isalpha(): - # TNS - res = call_resolver(query['object'], 'tns', timeout=timeout) - if res: - query['object'] = res[0]['d:fullname'] - query['type'] = 'tns' - query['hint'] = 'TNS object' - query['params']['ra'] = res[0]['d:ra'] - query['params']['dec'] = res[0]['d:declination'] - if 'ra' not in query['params']: # SSO - final test res = call_resolver(query['object'], 'ssodnet', timeout=timeout) @@ -259,6 +274,9 @@ def parse_query(string, timeout=None): query['type'] = 'ssodnet' query['hint'] = 'SSO object / {}'.format(res[0]['type']) + if len(res) > 1: + query['completions'] = [_['name'] for _ in res] + # Guess the kind of query if 'ra' in query['params'] and 'dec' in query['params']: query['action'] = 'conesearch' diff --git a/magicbar/smartsearch.py b/magicbar/smartsearch.py index f2217a5c..8c9c697f 100644 --- a/magicbar/smartsearch.py +++ b/magicbar/smartsearch.py @@ -1,5 +1,5 @@ import dash -from dash import html, dcc, Input, Output, State, dash_table, no_update, ctx, clientside_callback +from dash import html, dcc, Input, Output, State, dash_table, no_update, ctx, clientside_callback, ALL import requests import dash_bootstrap_components as dbc from dash.exceptions import PreventUpdate @@ -172,7 +172,28 @@ def update_suggestions(n_intervals, value): if not query['action']: return None, no_update - content = [ + content = [] + + if query['completions']: + content += [ + html.Div( + [ + html.Span('Did you mean:', className='text-secondary'), + ] + [ + dmc.Button( + __, + id={'type': 'magic_completion', 'index': _}, + variant='subtle', + size='sm', + compact=True, + n_clicks=0 + ) for _,__ in enumerate(query['completions']) + ], + className="border-bottom mb-1" + ) + ] + + content += [ dmc.Group([ html.Strong(query['object']) if query['object'] else None, dmc.Badge(query['type'], variant="outline", color='blue') if query['type'] else None, @@ -182,9 +203,9 @@ def update_suggestions(n_intervals, value): ] if len(params): - content.append( + content += [ html.Small(" ".join(["{}={}".format(_,params[_]) for _ in params])) - ) + ] suggestion = dbc.ListGroupItem( content, @@ -198,6 +219,21 @@ def update_suggestions(n_intervals, value): else: return no_update, no_update +# Completion clicked +@app.callback( + Output('magic_search', 'value'), + Input({'type': 'magic_completion', 'index': ALL}, 'n_clicks'), + State({'type': 'magic_completion', 'index': ALL}, 'children'), + prevent_initial_call=True +) +def on_completion(n_clicks, values): + if ctx.triggered[0]['value']: + # print(ctx.triggered_id) + # print(values[ctx.triggered_id['index']]) + return values[ctx.triggered_id['index']] + + return no_update + # Submit the results @app.callback( Output('check', 'children'), From 29876e86e506d2d0324d969b79ab4c3f5d178e09 Mon Sep 17 00:00:00 2001 From: Sergey Karpov Date: Tue, 12 Dec 2023 19:09:01 +0100 Subject: [PATCH 6/6] Minor improvements for TNS objects --- magicbar/parse.py | 6 +++--- magicbar/smartsearch.py | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/magicbar/parse.py b/magicbar/parse.py index 654e58a7..3f42c8c9 100644 --- a/magicbar/parse.py +++ b/magicbar/parse.py @@ -26,9 +26,9 @@ def call_resolver(data, kind, timeout=None, reverse=False): if kind == 'tns': # Normalize AT name to have whitespace before year - m = re.match('^(AT)\s*(2\w+)$', data) + m = re.match('^(AT|SN)\s*([12]\w+)$', data, re.IGNORECASE) if m: - data = m[1] + ' ' + m[2] + data = m[1].upper() + ' ' + m[2] try: if kind == 'ztf': @@ -242,7 +242,7 @@ def parse_query(string, timeout=None): if res: query['object'] = res[0]['d:fullname'] query['type'] = 'tns' - query['hint'] = 'TNS object' + query['hint'] = 'TNS object / {}'.format(res[0]['d:internalname']) query['params']['ra'] = res[0]['d:ra'] query['params']['dec'] = res[0]['d:declination'] diff --git a/magicbar/smartsearch.py b/magicbar/smartsearch.py index 8c9c697f..2e196430 100644 --- a/magicbar/smartsearch.py +++ b/magicbar/smartsearch.py @@ -180,16 +180,14 @@ def update_suggestions(n_intervals, value): [ html.Span('Did you mean:', className='text-secondary'), ] + [ - dmc.Button( + html.A( __, id={'type': 'magic_completion', 'index': _}, - variant='subtle', - size='sm', - compact=True, - n_clicks=0 + n_clicks=0, + className='ms-2 link text-decoration-none' ) for _,__ in enumerate(query['completions']) ], - className="border-bottom mb-1" + className="border-bottom p-1 mb-1 mt-1" ) ]