diff --git a/lmfdb/number_fields/number_field.py b/lmfdb/number_fields/number_field.py index f2fa21deab..29c9f8c9fb 100644 --- a/lmfdb/number_fields/number_field.py +++ b/lmfdb/number_fields/number_field.py @@ -17,6 +17,7 @@ parse_floats, parse_subfield, search_wrap, parse_padicfields, integer_options, raw_typeset, raw_typeset_poly, flash_info, input_string_to_poly, raw_typeset_int, compress_poly_Q, compress_polynomial, CodeSnippet, redirect_no_cache) +from lmfdb.utils.search_wrapper import multi_entry_jump_search from lmfdb.utils.web_display import compress_int from lmfdb.utils.interesting import interesting_knowls from lmfdb.utils.search_columns import SearchColumns, SearchCol, CheckCol, MathCol, ProcessedCol, MultiProcessedCol, CheckMaybeCol, PolynomialCol @@ -817,6 +818,17 @@ def interesting(): def number_field_jump(info): + # If jump box input is a comma-separated list of labels/names/polynomials, then use multi_entry_jump_search to return a search page + multi_jump = multi_entry_jump_search( + info, + parse_entry=nf_string_to_label, + label_exists=db.nf_fields.label_exists, + index_endpoint=".number_field_render_webpage", + object_name="number fields", + ) + if multi_jump is not None: + return multi_jump + query = {'label_orig': info['jump']} try: parse_nf_string(info, query, 'jump', name="Label", qfield='label') @@ -1165,6 +1177,7 @@ def nf_code(**args): class NFSearchArray(SearchArray): noun = "field" + label_knowl = "nf.label" sorts = [("", "degree", ['degree', 'disc_abs', 'disc_sign', 'iso_number']), ("signature", "signature", ['degree', 'r2', 'disc_abs', 'disc_sign', 'iso_number']), ("rd", "root discriminant", ['rd', 'degree', 'disc_abs', 'disc_sign', 'iso_number']), @@ -1177,7 +1190,7 @@ class NFSearchArray(SearchArray): jump_example = "x^7 - x^6 - 3 x^5 + x^4 + 4 x^3 - x^2 - x + 1" jump_egspan = r"e.g. 2.2.5.1, Qsqrt5, x^2-5, or x^2-x-1 for \(\Q(\sqrt{5})\)" jump_knowl = "nf.search_input" - jump_prompt = "Label, name, or polynomial" + jump_prompt = "Label, name, polynomial, or comma-separated list" has_diagram = False def __init__(self): diff --git a/lmfdb/number_fields/test_numberfield.py b/lmfdb/number_fields/test_numberfield.py index 89c394ac07..d6d22f0aff 100644 --- a/lmfdb/number_fields/test_numberfield.py +++ b/lmfdb/number_fields/test_numberfield.py @@ -34,6 +34,14 @@ def test_search_zeta(self): def test_search_sqrt(self): self.check_args('/NumberField/?jump=Qsqrt-163&search=Go', '41') # minpoly + def test_search_multiple_fields(self): + # Test comma-separated list of field labels + self.check_args('/NumberField/?jump=2.2.5.1%2c+3.3.49.1&search=Go', '2.2.5.1') + self.check_args('/NumberField/?jump=2.2.5.1%2c+3.3.49.1&search=Go', '3.3.49.1') + # Test comma-separated list with different input formats + self.check_args('/NumberField/?jump=Qsqrt5%2c+x%5E2-3&search=Go', '2.2.5.1') + self.check_args('/NumberField/?jump=Qsqrt5%2c+x%5E2-3&search=Go', '2.2.12.1') + def test_search_disc(self): self.check_args('/NumberField/?discriminant=1988-2014', '401') # factor of one of the discriminants diff --git a/lmfdb/utils/search_boxes.py b/lmfdb/utils/search_boxes.py index e753d2a8d1..ee5ca7bf68 100644 --- a/lmfdb/utils/search_boxes.py +++ b/lmfdb/utils/search_boxes.py @@ -663,6 +663,7 @@ class SearchArray(UniqueRepresentation): """ _ex_col_width = 170 # only used for box layout sort_knowl = None + label_knowl = None # Link to knowl for label (used for "labels" SneakyBox) sorts = None # Provides an easy way to implement sort_order: a list of triples (name, display, sort -- as a list of columns or pairs (col, +-1)), or a dictionary indexed on the value of self._st() null_column_explanations = {} # Can override the null warnings for a column by including False as a value, or customize the error message by giving a formatting string (see search_wrapper.py) noun = "result" @@ -711,7 +712,8 @@ def main_array(self, info): if info is None: return self.browse_array else: - return self.refine_array + # Append a "Labels" search box allowing multiple labels to be passed via URL + return self.refine_array + [[SneakyTextBox(name="labels", label="Labels", knowl=self.label_knowl)]] def _print_table(self, grid, info, layout_type): if not grid: diff --git a/lmfdb/utils/search_wrapper.py b/lmfdb/utils/search_wrapper.py index cc9cc6a0b7..5e700a4c9d 100644 --- a/lmfdb/utils/search_wrapper.py +++ b/lmfdb/utils/search_wrapper.py @@ -1,5 +1,6 @@ +import time from random import randrange -from flask import render_template, jsonify, redirect, request +from flask import render_template, jsonify, redirect, request, url_for from urllib.parse import urlparse from psycopg2.extensions import QueryCanceledError from psycopg2.errors import NumericValueOutOfRange @@ -38,6 +39,134 @@ def use_split_ors(info, query, split_ors, offset, table): ) +def split_top_level_commas(text): + """ + A function which takes an input string and returns a list of strings, splitting on commas that are not inside parentheses/brackets/braces. + Used as the default separator function when parsing jump box input for multiple entries. + """ + + entries = [] + chunk = [] + depth = 0 + for ch in text: + if ch in "([{": + depth += 1 + chunk.append(ch) + elif ch in ")]}": + depth = max(depth - 1, 0) + chunk.append(ch) + elif ch == "," and depth == 0: + entry = "".join(chunk).strip() + if entry: + entries.append(entry) + chunk = [] + else: + chunk.append(ch) + + entry = "".join(chunk).strip() + if entry: + entries.append(entry) + return entries + + +def multi_entry_jump_search(info, parse_entry, label_exists, index_endpoint, input_key="jump", labels_key="labels", + sep=split_top_level_commas, object_name="records", time_limit=30): + """ + Generic handler for jump boxes that supports comma-separated input of various entries (labels/names/polynomials/equations etc.). + + Returns ``None`` if there is at most one entry, allowing the caller's single-entry jump logic to run. + Otherwise returns a redirect to a search page of the given labels. + + INPUT: + + - ``info`` -- the info dictionary passed in from front end + - ``parse_entry`` -- a custom function which converts a string (e.g. polynomial, equation, nickname, etc.) to be parsed into a label + - ``label_exists`` -- a custom function which determines whether a given label exists in the database + - ``index_endpoint`` -- the input to "url_for" which returns the index homepage for this section + - ``input_key`` -- the dictionary key for the jump search box (default: "jump") + - ``labels_key`` -- the dictionary key for the labels search query (default: "labels") + - ``sep`` -- A function used to separate out jump box input into separate entries (default: split_top_level_commas) + - ``object_name`` -- The name of the objects in the database (e.g. "fields", "elliptic curves"). Used when flashing info or error messages. + - ``time_limit`` -- a time limit (in seconds) for the maximum amount of time this query should take (default: 30) + """ + + jump_input = info.get(input_key, "") + entries = [s.strip() for s in sep(jump_input) if s.strip()] + if len(entries) <= 1: + return None + + # For each entry given in the comma-separated jump box input, we attempt to parse the entry using parse_entry (while skipping duplicates) + # If the user inputs a large number of entries, this may take a long time (e.g. for number fields, this might require calling Pari's polredabs on every entry) + # We start a timer and stop parsing entries if we've hit the specified time_limit (default: 30 seconds) + + labels, seen = [], set() + not_parsed, not_found = 0, 0 + start_timer = time.monotonic() + for i in range(len(entries)): + # Check if exceeded time limit + if time.monotonic() - start_timer > time_limit: + flash_error("Query timed out after parsing the first %s entries in the input.", i) + return redirect(url_for(index_endpoint)) + + # Attempt to parse entry + try: + label = parse_entry(entries[i]) + except (SearchParsingError, ValueError): + not_parsed += 1 + continue + if not label_exists(label): + not_found += 1 + continue + if label not in seen: + labels.append(label) + seen.add(label) + + # Flash error if no entries successfully parsed + if not labels: + flash_error("None of the %s entries matched %s in the database.", len(entries), object_name) + return redirect(url_for(index_endpoint)) + + # Otherwise flash info message with number of entries we are able to parse + ignored = not_parsed + not_found + duplicates = len(entries) - ignored - len(labels) + if ignored: + flash_info("Matched %s of %s entries; ignored %s unrecognized or missing entries.", len(labels), len(entries), ignored) + if duplicates > 0: + flash_info("Removed %s duplicate label(s).", duplicates) + + return redirect(url_for(index_endpoint, **{labels_key: ",".join(labels)})) + + +def parse_labels(info, query, table, labels_key="labels"): + """ + Parse a list of labels from the URL "?labels=" query into a database query. + """ + labels_input = info.get(labels_key) + if not labels_input or not hasattr(table, "_label_col"): + return + + # Separate out labels from input, stripping whitespace and removing duplicates while preserving order + labels = list(set(label.strip() for label in labels_input.split(","))) + seen = set(labels) + if not labels: + return + + label_col = table._label_col + existing = query.get(label_col) + if existing is None: + query[label_col] = {"$in": labels} + elif isinstance(existing, dict): + if "$in" in existing: + existing["$in"] = [label for label in existing["$in"] if label in seen] + else: + # Keep existing constraints and add an $in constraint as well. + existing["$in"] = labels + else: + # Existing exact match constraint: keep it only if it appears in labels. + if existing not in seen: + query[label_col] = {"$in": []} + + class Wrapper: def __init__( self, @@ -85,6 +214,7 @@ def make_query(self, info, random=False): template_kwds = {key: info.get(key, val()) for key, val in self.kwds.items()} try: errpage = self.f(info, query) + parse_labels(info, query, self.table) except Exception as err: # Errors raised in parsing; these should mostly be SearchParsingErrors if is_debug_mode():