diff --git a/Tekst-API/openapi.json b/Tekst-API/openapi.json index f5ea69940..3e996ff91 100644 --- a/Tekst-API/openapi.json +++ b/Tekst-API/openapi.json @@ -7348,13 +7348,13 @@ }, "strict": false }, - "optionalNullable": true + "optionalNullable": false }, "adv": { "$ref": "#/components/schemas/AdvancedSearchSettings", "description": "Advanced search settings", "default": {}, - "optionalNullable": true + "optionalNullable": false } }, "type": "object", @@ -7720,7 +7720,8 @@ "type": "boolean", "title": "Defaultactive", "description": "Whether this resource is active by default when public", - "default": true + "default": true, + "optionalNullable": false }, "enableContentContext": { "type": "boolean", @@ -7750,7 +7751,8 @@ "type": "boolean", "title": "Rtl", "description": "Whether to display text contents in right-to-left direction", - "default": false + "default": false, + "optionalNullable": false }, "osk": { "anyOf": [ @@ -9776,31 +9778,36 @@ "type": "boolean", "title": "Defaultactive", "description": "Whether this resource is active by default when public", - "default": true + "default": true, + "optionalNullable": false }, "enableContentContext": { "type": "boolean", "title": "Enablecontentcontext", "description": "Show combined contents of this resource on the parent level", - "default": false + "default": false, + "optionalNullable": false }, "searchableQuick": { "type": "boolean", "title": "Searchablequick", "description": "Whether this resource should be included in quick search", - "default": true + "default": true, + "optionalNullable": false }, "searchableAdv": { "type": "boolean", "title": "Searchableadv", "description": "Whether this resource should accessible via advanced search", - "default": true + "default": true, + "optionalNullable": false }, "rtl": { "type": "boolean", "title": "Rtl", "description": "Whether to display text contents in right-to-left direction", - "default": false + "default": false, + "optionalNullable": false }, "osk": { "anyOf": [ @@ -11193,7 +11200,7 @@ "pg": 1, "pgs": 10 }, - "optionalNullable": true + "optionalNullable": false }, "sort": { "anyOf": [ @@ -11205,7 +11212,7 @@ } ], "description": "Sorting preset", - "optionalNullable": true + "optionalNullable": false }, "strict": { "type": "boolean", @@ -12674,14 +12681,14 @@ "title": "Pg", "description": "Page number", "default": 1, - "optionalNullable": true + "optionalNullable": false }, "pgs": { "type": "integer", "title": "Pgs", "description": "Page size", "default": 10, - "optionalNullable": true + "optionalNullable": false } }, "type": "object", @@ -14229,16 +14236,18 @@ }, "strict": false }, - "optionalNullable": true + "optionalNullable": false }, "qck": { "$ref": "#/components/schemas/QuickSearchSettings", "description": "Quick search settings", "default": { "op": "OR", - "re": false + "re": false, + "inh": false, + "allLvls": false }, - "optionalNullable": true + "optionalNullable": false } }, "type": "object", @@ -14255,14 +14264,28 @@ "title": "Op", "description": "Default operator", "default": "OR", - "optionalNullable": true + "optionalNullable": false }, "re": { "type": "boolean", "title": "Re", "description": "Whether to use regular expressions", "default": false, - "optionalNullable": true + "optionalNullable": false + }, + "inh": { + "type": "boolean", + "title": "Inh", + "description": "Whether to match contents inherited from higher-level locations", + "default": false, + "optionalNullable": false + }, + "allLvls": { + "type": "boolean", + "title": "Alllvls", + "description": "Whether to find locations from all levels, as opposed to only finding locations from the respective text's default level", + "default": false, + "optionalNullable": false }, "txt": { "anyOf": [ diff --git a/Tekst-API/tekst/logs.py b/Tekst-API/tekst/logs.py index 573ce4a65..23445f475 100644 --- a/Tekst-API/tekst/logs.py +++ b/Tekst-API/tekst/logs.py @@ -143,7 +143,8 @@ def log_op_end( global _running_ops op_entry = _running_ops.pop(op_id, None) if op_entry is None: # pragma: no cover - raise RuntimeError(f"Operation {op_id} not found in running operations dict") + log.error(f"Operation {op_id} not found in running operations dict") + return -1 label, start_t, level_code, use_proc_t = op_entry dur = (process_time() if use_proc_t else perf_counter()) - start_t if not failed: diff --git a/Tekst-API/tekst/models/resource_configs.py b/Tekst-API/tekst/models/resource_configs.py index 7d6bcddc0..edceb8d35 100644 --- a/Tekst-API/tekst/models/resource_configs.py +++ b/Tekst-API/tekst/models/resource_configs.py @@ -4,7 +4,7 @@ from tekst.models.common import ModelBase from tekst.models.platform import OskKey -from tekst.types import ConStrOrNone +from tekst.types import ConStrOrNone, SchemaOptionalNonNullable class CommonResourceConfig(ModelBase): @@ -29,30 +29,35 @@ class CommonResourceConfig(ModelBase): Field( description="Whether this resource is active by default when public", ), + SchemaOptionalNonNullable, ] = True enable_content_context: Annotated[ bool, Field( description="Show combined contents of this resource on the parent level", ), + SchemaOptionalNonNullable, ] = False searchable_quick: Annotated[ bool, Field( description="Whether this resource should be included in quick search", ), + SchemaOptionalNonNullable, ] = True searchable_adv: Annotated[ bool, Field( description="Whether this resource should accessible via advanced search", ), + SchemaOptionalNonNullable, ] = True rtl: Annotated[ bool, Field( description="Whether to display text contents in right-to-left direction", ), + SchemaOptionalNonNullable, ] = False osk: OskKey | None = None diff --git a/Tekst-API/tekst/models/search.py b/Tekst-API/tekst/models/search.py index 1c22320bd..4799f51f3 100644 --- a/Tekst-API/tekst/models/search.py +++ b/Tekst-API/tekst/models/search.py @@ -94,7 +94,7 @@ class PaginationSettings(ModelBase): alias="pg", description="Page number", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = 1 page_size: Annotated[ int, @@ -103,7 +103,7 @@ class PaginationSettings(ModelBase): alias="pgs", description="Page size", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = 10 def es_from(self) -> int: @@ -126,7 +126,7 @@ class GeneralSearchSettings(ModelBase): alias="pgn", description="Pagination settings", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = PaginationSettings() sorting_preset: Annotated[ SortingPreset | None, @@ -134,7 +134,7 @@ class GeneralSearchSettings(ModelBase): alias="sort", description="Sorting preset", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = None strict: bool = False @@ -146,7 +146,7 @@ class QuickSearchSettings(ModelBase): alias="op", description="Default operator", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = "OR" regexp: Annotated[ bool, @@ -154,7 +154,28 @@ class QuickSearchSettings(ModelBase): alias="re", description="Whether to use regular expressions", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, + ] = False + inherited_contents: Annotated[ + bool, + Field( + alias="inh", + description=( + "Whether to match contents inherited from higher-level locations" + ), + ), + SchemaOptionalNonNullable, + ] = False + all_levels: Annotated[ + bool, + Field( + alias="allLvls", + description=( + "Whether to find locations from all levels, as opposed to only finding " + "locations from the respective text's default level" + ), + ), + SchemaOptionalNonNullable, ] = False texts: Annotated[ list[PydanticObjectId] | None, @@ -201,7 +222,7 @@ class QuickSearchRequestBody(ModelBase): alias="gen", description="General search settings", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = GeneralSearchSettings() settings_quick: Annotated[ QuickSearchSettings, @@ -209,7 +230,7 @@ class QuickSearchRequestBody(ModelBase): alias="qck", description="Quick search settings", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = QuickSearchSettings() @@ -237,7 +258,7 @@ class AdvancedSearchRequestBody(ModelBase): alias="gen", description="General search settings", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = GeneralSearchSettings() settings_advanced: Annotated[ AdvancedSearchSettings, @@ -245,7 +266,7 @@ class AdvancedSearchRequestBody(ModelBase): alias="adv", description="Advanced search settings", ), - SchemaOptionalNullable, + SchemaOptionalNonNullable, ] = AdvancedSearchSettings() diff --git a/Tekst-API/tekst/resources/__init__.py b/Tekst-API/tekst/resources/__init__.py index fe21d66fb..c1eaf2d01 100644 --- a/Tekst-API/tekst/resources/__init__.py +++ b/Tekst-API/tekst/resources/__init__.py @@ -164,6 +164,9 @@ def index_mappings( strict_analyzer=strict_analyzer, ) return dict( + native={ + "type": "boolean", + }, comment={ "type": "text", "analyzer": "standard_no_diacritics", @@ -176,11 +179,14 @@ def index_mappings( def index_doc( cls, content: ContentBase, + *, + native: bool = True, ) -> dict[str, Any]: """ Returns the content for the ES index document for this type of resource content """ return dict( + native=native, comment=content.comment, **(cls._rtype_index_doc(content) or {}), ) diff --git a/Tekst-API/tekst/routers/search.py b/Tekst-API/tekst/routers/search.py index a1b9c0ff5..0af1d87be 100644 --- a/Tekst-API/tekst/routers/search.py +++ b/Tekst-API/tekst/routers/search.py @@ -57,7 +57,7 @@ async def perform_search( if body.search_type == "quick": return await search.search_quick( user=user, - query_string=body.query, + user_query=body.query, settings_general=body.settings_general, settings_quick=body.settings_quick, ) diff --git a/Tekst-API/tekst/search/__init__.py b/Tekst-API/tekst/search/__init__.py index e3eb296ed..f898aa39c 100644 --- a/Tekst-API/tekst/search/__init__.py +++ b/Tekst-API/tekst/search/__init__.py @@ -41,6 +41,8 @@ from tekst.search.utils import ( add_analysis_settings, add_mappings, + quick_qstr_query, + quick_regexp_query, ) from tekst.state import get_state, update_state @@ -175,6 +177,8 @@ async def create_indices_task(force: bool = False) -> dict[str, float]: await _populate_index(new_idx_name, text) except Exception as e: # pragma: no cover log_op_end(populate_op_id, failed=True, failed_msg=str(e)) + client.indices.delete(index=new_idx_name) # delete broken/unfinished index + raise e utd_idxs.append(new_idx_name) # mark created index as "up to date" text.index_utd = True @@ -228,19 +232,22 @@ async def _populate_index( def _bulk_index( reqest_body: dict[str, Any], req_no: int = None, - ) -> bool: + ) -> None: resp = client.bulk( body=reqest_body, timeout=f"{_cfg.es.timeout_general_s}s", refresh="wait_for", ) + if resp.get("errors", False): # pragma: no cover + for error in resp["items"]: + log.error(str(error)) + raise RuntimeError(f"Failed to index documents for text '{text.title}'.") req_no_str = f"#{req_no} " if req_no is not None else "" log.debug(f"Bulk index request {req_no_str}took: {resp.get('took', '???')}ms") - return bool(resp) and not resp.get("errors", False) bulk_index_max_size = 200 bulk_index_body = [] - errors = False + target_resource_ids = [ res.id for res in await _get_resources( @@ -250,10 +257,11 @@ def _bulk_index( ] # Initialize stack with all level 0 locations (sorted) of the current text. - # Each item on the stack is a tuple containing (1) the location labels from the - # root level up to the current location and (2) the location itself. + # Each item on the stack is a tuple containing + # (0) the location itself as LocationDocument + # (1) location labels from the root level up to the current location as list[str], stack = [ - ([location.label], location) + (location, [location.label]) for location in await LocationDocument.find( LocationDocument.text_id == text.id, LocationDocument.level == 0, @@ -265,51 +273,74 @@ def _bulk_index( # abort if initial stack is empty if not stack: # pragma: no cover return + + # cache mapping of location IDs to index doc contents + # for re-use in child location index docs + parent_idx_contents: dict[str, dict[str, Any]] = {} + + # keep track of number of bulk index requests bulk_req_count = 0 - while stack: - labels, location = stack.pop(0) - full_label = text.loc_delim.join(labels) + while len(stack): + loc, labels = stack.pop(0) + loc_id_str = str(loc.id) # create index document for this location - location_index_doc = { - "label": location.label, - "full_label": full_label, - "text_id": str(location.text_id), - "level": location.level, - "position": location.position, + loc_idx_doc = { + "label": loc.label, + "full_label": text.loc_delim.join(labels), + "text_id": str(loc.text_id), + "level": loc.level, + "position": loc.position, "resources": {}, } + # add parent contents + if str(loc.parent_id) in parent_idx_contents: + loc_idx_doc["resources"].update(parent_idx_contents[str(loc.parent_id)]) + # add data for each content for this location for content in await ContentBaseDocument.find( - Eq(ContentBaseDocument.location_id, location.id), + Eq(ContentBaseDocument.location_id, loc.id), In(ContentBaseDocument.resource_id, target_resource_ids), with_children=True, ).to_list(): - # add resource content document to location index document - location_index_doc["resources"][str(content.resource_id)] = ( - resource_types_mgr.get(content.resource_type).index_doc(content) - ) + # add resource level and content to location index document + loc_idx_doc["resources"][str(content.resource_id)] = resource_types_mgr.get( + content.resource_type + ).index_doc(content=content, native=True) + + # add location contents to cached parent contents + # (only if the current location's level is < max level, + # otherwise there won't be any child locations we need that content for) + # but set "native" to False, as these contents aren't native to child locations + if loc.level < len(text.levels) - 1: + parent_idx_contents[loc_id_str] = {} + for res_id in loc_idx_doc["resources"]: + parent_idx_contents[loc_id_str][res_id] = { + **loc_idx_doc["resources"][res_id], + "native": False, + } # add index document to bulk index request body - bulk_index_body.append( - {"index": {"_index": index_name, "_id": str(location.id)}} - ) - bulk_index_body.append(location_index_doc) + bulk_index_body.append({"index": {"_index": index_name, "_id": loc_id_str}}) + bulk_index_body.append(loc_idx_doc) # check bulk request body size, fire bulk request if necessary if len(bulk_index_body) / 2 >= bulk_index_max_size: # pragma: no cover bulk_req_count += 1 - errors |= not _bulk_index(bulk_index_body, bulk_req_count) + _bulk_index(bulk_index_body, bulk_req_count) bulk_index_body = [] - # add all child locations to the stack + # add all child locations to the processing stack stack.extend( [ - (labels + [child.label], child) + ( + child, # the target location document + labels + [child.label], # all the labels + ) for child in await LocationDocument.find( - LocationDocument.parent_id == location.id, + LocationDocument.parent_id == loc.id, ) .sort(+LocationDocument.position) .to_list() @@ -317,12 +348,9 @@ def _bulk_index( ) # index the remaining documents - errors |= not _bulk_index(bulk_index_body, bulk_req_count + 1) + _bulk_index(bulk_index_body, bulk_req_count + 1) bulk_index_body = [] - if errors: # pragma: no cover - raise RuntimeError(f"Failed to index some documents for text '{text.title}'.") - async def _get_mapped_fields_count(index: str) -> int: client: Elasticsearch = await _get_es_client() @@ -405,48 +433,91 @@ async def _get_resources( async def search_quick( user: UserRead | None, - query_string: str | None = None, + user_query: str | None = None, settings_general: GeneralSearchSettings = GeneralSearchSettings(), settings_quick: QuickSearchSettings = QuickSearchSettings(), ) -> SearchResults: client: Elasticsearch = _es_client + + # get (pre-)selection of target resources target_resources = await _get_resources( user=user, text_ids=settings_quick.texts, # constrain target texts ) + # remove resources that aren't quick-searchable + target_resources = [ + res for res in target_resources if res.config.common.searchable_quick + ] + # compose a list of target index fields based on the resources to search: field_pattern_suffix = ".strict" if settings_general.strict else "" - fields = [] + fields = [] # list of tuples of (res_id, field_path) for res in target_resources: - if res.config.common.searchable_quick: - for field in res.quick_search_fields(): - fields.append(f"resources.{str(res.id)}.{field}{field_pattern_suffix}") + for field in res.quick_search_fields(): + fields.append( + ( + str(res.id), + f"resources.{str(res.id)}.{field}{field_pattern_suffix}", + ) + ) - # compose the query - if not settings_quick.regexp or not query_string: - es_query = { - "simple_query_string": { - "query": query_string or "*", # fall back to '*' if empty - "fields": fields, - "default_operator": settings_quick.default_operator, - "analyze_wildcard": True, - } - } + # create ES content query + if not settings_quick.regexp or not user_query: + # use q query string query + es_query = quick_qstr_query( + user_query, + fields, + default_op=settings_quick.default_operator, + inherited_contents=settings_quick.inherited_contents, + ) else: + # use regexp query + es_query = quick_regexp_query( + user_query, + fields, + inherited_contents=settings_quick.inherited_contents, + ) + + # if "all_levels" is set to `False`, modify ES query to + # only find locations on their text's default level + if not settings_quick.all_levels: + # get target texts (mapped by text ID) + texts = await TextDocument.find( + In(TextDocument.id, settings_quick.texts) if settings_quick.texts else {} + ).to_list() + # construct query es_query = { "bool": { - "should": [ + "must": [ + es_query, # original query from above { - "regexp": { - field: { - "value": query_string, - "flags": "ALL", - "case_insensitive": True, - } + "bool": { + "should": [ + { + "bool": { + "filter": [ + { + "term": { + "text_id": { + "value": str(text.id), + } + } + }, + { + "term": { + "level": { + "value": text.default_level, + } + } + }, + ] + } + } + for text in texts + ] } - } - for field in fields + }, ] } } @@ -459,7 +530,7 @@ async def search_quick( index=IDX_ALIAS, query=es_query, highlight={ - "fields": [{field: {}} for field in fields], + "fields": [{field_path: {}} for _, field_path in fields], }, from_=settings_general.pagination.es_from(), size=settings_general.pagination.es_size(), diff --git a/Tekst-API/tekst/search/templates.py b/Tekst-API/tekst/search/templates.py index 34df9e000..d44512d35 100644 --- a/Tekst-API/tekst/search/templates.py +++ b/Tekst-API/tekst/search/templates.py @@ -51,12 +51,14 @@ }, }, "mappings": { - "dynamic": False, + "dynamic": "strict", "properties": { "resources": {"type": "object"}, "text_id": {"type": "keyword"}, - "level": {"type": "short"}, + "level": {"type": "byte"}, "position": {"type": "integer"}, + "label": {"type": "keyword", "index": False}, + "full_label": {"type": "keyword", "index": False}, }, }, } diff --git a/Tekst-API/tekst/search/utils.py b/Tekst-API/tekst/search/utils.py index c41e69c1e..759473731 100644 --- a/Tekst-API/tekst/search/utils.py +++ b/Tekst-API/tekst/search/utils.py @@ -79,3 +79,87 @@ def add_mappings( strict_analyzer=strict_analyzer, ), } + + +def quick_qstr_query( + user_query: str, + fields: list[tuple[str, str]], + *, + default_op: str = "OR", + inherited_contents: bool = False, +) -> dict[str, Any]: + """ + Returns a simple query string query for all fields in `fields`. + `fields` is a list of tuples of (res_id, field_path). + """ + return { + "bool": { + "should": [ + { + "bool": { + "should" if inherited_contents else "must": [ + { + "simple_query_string": { + "query": user_query or "*", + "fields": [field_path], + "default_operator": default_op, + "analyze_wildcard": True, + } + }, + { + "term": { + f"resources.{res_id}.native": { + "value": True, + "boost": 2, + } + } + }, + ] + } + } + for res_id, field_path in fields + ] + } + } + + +def quick_regexp_query( + user_query: str, + fields: list[tuple[str, str]], + *, + inherited_contents: bool = False, +) -> dict[str, Any]: + """ + Returns a regexp query for all fields in `fields`. + `fields` is a list of tuples of (res_id, field_path). + """ + return { + "bool": { + "should": [ + { + "bool": { + "should" if inherited_contents else "must": [ + { + "regexp": { + field_path: { + "value": user_query, + "flags": "ALL", + "case_insensitive": True, + } + } + }, + { + "term": { + f"resources.{res_id}.native": { + "value": True, + "boost": 2, + } + } + }, + ] + } + } + for res_id, field_path in fields + ] + } + } diff --git a/Tekst-API/tests/test_api_search.py b/Tekst-API/tests/test_api_search.py index 7bcc7106a..5c4ce72f5 100644 --- a/Tekst-API/tests/test_api_search.py +++ b/Tekst-API/tests/test_api_search.py @@ -61,27 +61,25 @@ async def test_quick( use_indices, assert_status, ): - # find everything + # find everything, default settings _assert_search_resp( await test_client.post( "/search", json={ "type": "quick", "q": "*", - "qck": {"op": "OR", "re": False, "txt": []}, }, ), - expected_hits=12, + expected_hits=8, ) - # simple without wildcards or regexes + # simple without wildcards or regexes, default settings _assert_search_resp( await test_client.post( "/search", json={ "type": "quick", "q": "foo", - "qck": {"op": "OR", "re": False, "txt": []}, }, ), expected_hits=2, @@ -99,7 +97,6 @@ async def test_quick( "sort": "relevance", "strict": True, }, - "qck": {"op": "OR", "re": False, "txt": []}, }, ), expected_hits=1, @@ -117,7 +114,6 @@ async def test_quick( "sort": "relevance", "strict": True, }, - "qck": {"op": "OR", "re": False, "txt": []}, }, ), expected_hits=2, @@ -130,7 +126,6 @@ async def test_quick( json={ "type": "quick", "q": "b*", - "qck": {"op": "OR", "re": False, "txt": []}, }, ), expected_hits=4, @@ -143,7 +138,6 @@ async def test_quick( json={ "type": "quick", "q": '"foo foo"', - "qck": {"op": "OR", "re": False, "txt": []}, }, ), expected_hits=1, @@ -156,7 +150,6 @@ async def test_quick( json={ "type": "quick", "q": '"foö word"~6', - "qck": {"op": "OR", "re": False, "txt": []}, }, ), expected_hits=1, @@ -169,7 +162,6 @@ async def test_quick( json={ "type": "quick", "q": "fuo~", - "qck": {"op": "OR", "re": False, "txt": []}, }, ), expected_hits=4, @@ -182,7 +174,7 @@ async def test_quick( json={ "type": "quick", "q": "b.*", - "qck": {"op": "OR", "re": True, "txt": []}, + "qck": {"re": True}, }, ), expected_hits=4, @@ -195,12 +187,38 @@ async def test_quick( json={ "type": "quick", "q": "b*", - "qck": {"op": "OR", "re": False, "txt": ["67c03aed5dbf06b9624fd57e"]}, + "qck": {"txt": ["67c03aed5dbf06b9624fd57e"]}, }, ), expected_hits=2, ) + # include inherited higher-level contents + _assert_search_resp( + await test_client.post( + "/search", + json={ + "type": "quick", + "q": "*", + "qck": {"inh": True}, + }, + ), + expected_hits=8, + ) + + # find locations on all levels + _assert_search_resp( + await test_client.post( + "/search", + json={ + "type": "quick", + "q": "*", + "qck": {"allLvls": True}, + }, + ), + expected_hits=8, + ) + # request too many results resp = await test_client.post( "/search", @@ -212,7 +230,6 @@ async def test_quick( "sort": "relevance", "strict": False, }, - "qck": {"op": "OR", "re": False, "txt": []}, }, ) assert_status(400, resp) diff --git a/Tekst-Web/i18n/ui/deDE.yml b/Tekst-Web/i18n/ui/deDE.yml index 5da896a85..f9b3438ba 100644 --- a/Tekst-Web/i18n/ui/deDE.yml +++ b/Tekst-Web/i18n/ui/deDE.yml @@ -912,6 +912,8 @@ search: quick: defaultOperator: Alle Begriffe müssen vorkommen regexp: Interpretiere Reguläre Ausdrücke + inheritedContents: Beziehe Inhalte übergeordneter Fundstellen ein + allLevels: Finde Fundstellen aller Ebenen texts: Texte textsPlaceholder: Alle Texte targetResources: Einbezogene Ressourcen auflisten @@ -928,6 +930,8 @@ search: browseStop: Suchergebnis-Navigation schließen indexCreationTime: Letztes update der Suchdaten msgInvalidRequest: Ungültige Suchanfrage. Haben Sie vielleicht einen Link zu dieser Seite kopiert und dabei ein paar Zeichen übersehen? + higherLvlHit: Suchtreffer von übergeordneter Ebene "{level}" + allTexts: allen Texten sortingPresets: title: Sortieren relevance: Relevanz diff --git a/Tekst-Web/i18n/ui/enUS.yml b/Tekst-Web/i18n/ui/enUS.yml index ed6477818..4400cfca9 100644 --- a/Tekst-Web/i18n/ui/enUS.yml +++ b/Tekst-Web/i18n/ui/enUS.yml @@ -888,6 +888,8 @@ search: quick: defaultOperator: All terms must occur regexp: Interpret Regular Expressions + inheritedContents: Include contents from higher-level locations + allLevels: Find locations from all levels texts: Texts textsPlaceholder: All texts targetResources: List included resources @@ -904,6 +906,8 @@ search: browseStop: Close search results navigation indexCreationTime: Last update of search data msgInvalidRequest: Invalid search request. Maybe you copied a link to this page and missed some characters? + higherLvlHit: Search hit from higher level "{level}" + allTexts: all texts sortingPresets: title: Sort relevance: Relevance diff --git a/Tekst-Web/prepare/schemaTransform.mjs b/Tekst-Web/prepare/schemaTransform.mjs index 8228dc4a6..0ad078a06 100644 --- a/Tekst-Web/prepare/schemaTransform.mjs +++ b/Tekst-Web/prepare/schemaTransform.mjs @@ -37,7 +37,7 @@ const ast = await openapiTS(SCHEMA_FILE_URL, { processedSchemaObjectHashes.add(hash); return { schema: transformSchemaObject(schemaObject, options), - questionToken: !!schemaObject.optionalNullable, // set field to optional + questionToken: true, // set field to optional }; } // handle binary types as `Blob` or `Blob | null` diff --git a/Tekst-Web/src/api/index.ts b/Tekst-Web/src/api/index.ts index 8b2a81b39..c6a22a37c 100644 --- a/Tekst-Web/src/api/index.ts +++ b/Tekst-Web/src/api/index.ts @@ -411,8 +411,10 @@ export type DeepLLinksConfig = components['schemas']['DeepLLinksConfig']; export type SearchResults = components['schemas']['SearchResults']; export type SearchHit = components['schemas']['SearchHit']; -export type QuickSearchRequestBody = components['schemas']['QuickSearchRequestBody']; -export type AdvancedSearchRequestBody = components['schemas']['AdvancedSearchRequestBody']; +export type QuickSearchRequestBody = Required; +export type AdvancedSearchRequestBody = Required< + components['schemas']['AdvancedSearchRequestBody'] +>; export type SortingPreset = components['schemas']['SortingPreset']; export type SearchPagination = { pg: number; pgs: number }; export type ResourceSearchQuery = components['schemas']['ResourceSearchQuery']; diff --git a/Tekst-Web/src/api/schema.d.ts b/Tekst-Web/src/api/schema.d.ts index 5a9b42323..0da03c93d 100644 --- a/Tekst-Web/src/api/schema.d.ts +++ b/Tekst-Web/src/api/schema.d.ts @@ -1250,7 +1250,7 @@ export interface components { * @description Resource-specific queries * @default [] */ - q: components['schemas']['ResourceSearchQuery'][]; + q?: components['schemas']['ResourceSearchQuery'][]; /** * @description General search settings * @default { @@ -1432,34 +1432,34 @@ export interface components { * @description Whether this resource is active by default when public * @default true */ - defaultActive: boolean; + defaultActive?: boolean; /** * Enablecontentcontext * @description Whether contents of this resource should be available for the parent level (always false for API call resources) * @default false * @constant */ - enableContentContext: false; + enableContentContext?: false; /** * Searchablequick * @description Whether this resource should be included in quick search (always false as API call contents are not searchable) * @default false * @constant */ - searchableQuick: false; + searchableQuick?: false; /** * Searchableadv * @description Whether this resource should accessible via advanced search (always false as API call contents are not searchable) * @default false * @constant */ - searchableAdv: false; + searchableAdv?: false; /** * Rtl * @description Whether to display text contents in right-to-left direction * @default false */ - rtl: boolean; + rtl?: boolean; /** Osk */ osk?: string | null; }; @@ -2408,31 +2408,31 @@ export interface components { * @description Whether this resource is active by default when public * @default true */ - defaultActive: boolean; + defaultActive?: boolean; /** * Enablecontentcontext * @description Show combined contents of this resource on the parent level * @default false */ - enableContentContext: boolean; + enableContentContext?: boolean; /** * Searchablequick * @description Whether this resource should be included in quick search * @default true */ - searchableQuick: boolean; + searchableQuick?: boolean; /** * Searchableadv * @description Whether this resource should accessible via advanced search * @default true */ - searchableAdv: boolean; + searchableAdv?: boolean; /** * Rtl * @description Whether to display text contents in right-to-left direction * @default false */ - rtl: boolean; + rtl?: boolean; /** Osk */ osk?: string | null; }; @@ -3127,7 +3127,7 @@ export interface components { */ pgn?: components['schemas']['PaginationSettings']; /** @description Sorting preset */ - sort?: components['schemas']['SortingPreset'] | null; + sort?: components['schemas']['SortingPreset']; /** * Strict * @default false @@ -4559,7 +4559,9 @@ export interface components { * @description Quick search settings * @default { * "op": "OR", - * "re": false + * "re": false, + * "inh": false, + * "allLvls": false * } */ qck?: components['schemas']['QuickSearchSettings']; @@ -4579,6 +4581,18 @@ export interface components { * @default false */ re?: boolean; + /** + * Inh + * @description Whether to match contents inherited from higher-level locations + * @default false + */ + inh?: boolean; + /** + * Alllvls + * @description Whether to find locations from all levels, as opposed to only finding locations from the respective text's default level + * @default false + */ + allLvls?: boolean; /** * Txt * @description IDs of texts to search in diff --git a/Tekst-Web/src/components/search/QuickSearch.vue b/Tekst-Web/src/components/search/QuickSearch.vue index ef4beafc6..4f82744f4 100644 --- a/Tekst-Web/src/components/search/QuickSearch.vue +++ b/Tekst-Web/src/components/search/QuickSearch.vue @@ -24,7 +24,6 @@ const router = useRouter(); const showLocationSelect = ref(false); const showSettingsModal = ref(false); const showTargetResourcesModal = ref(false); -const searchInput = ref(''); const loading = ref(false); const quickSearchInputRef = ref(null); @@ -61,7 +60,7 @@ async function handleSearch() { params: { query: { textId: state.text?.id || '', - alias: searchInput.value, + alias: search.queryQuick, limit: matchesToShow, }, }, @@ -103,8 +102,8 @@ async function handleSearch() { children: [ { type: 'search', - label: searchInput.value, - value: searchInput.value, + label: search.queryQuick, + value: search.queryQuick, }, ], }, @@ -113,7 +112,7 @@ async function handleSearch() { } } else { // there is no matching location alias, so we perform a quick search using the input - quickSearch(searchInput.value); + quickSearch(search.queryQuick); } loading.value = false; @@ -131,7 +130,7 @@ function handleSelect(value: string, option: SelectOption) { name: 'browse', params: { textSlug: state.text?.slug || '', locId: option.value }, }); - emit('submit', searchInput.value); + emit('submit', search.queryQuick); } else if (option.type === 'search') { quickSearch(value); } @@ -141,7 +140,7 @@ function handleSelect(value: string, option: SelectOption) { function quickSearch(q: string) { search.searchQuick(q); - emit('submit', searchInput.value); + emit('submit', search.queryQuick); } @@ -157,7 +156,7 @@ function quickSearch(q: string) { > - + diff --git a/Tekst-Web/src/components/search/QuickSearchQueryDisplay.vue b/Tekst-Web/src/components/search/QuickSearchQueryDisplay.vue index 906a87fef..3dde9460e 100644 --- a/Tekst-Web/src/components/search/QuickSearchQueryDisplay.vue +++ b/Tekst-Web/src/components/search/QuickSearchQueryDisplay.vue @@ -23,21 +23,21 @@ const state = useStateStore(); const theme = useThemeStore(); const targetTexts = computed(() => { - return ( - state.pf?.texts.filter((t) => - !!props.req.qck?.txt?.length ? props.req.qck.txt.includes(t.id) : true - ) || [] - ).map((t) => ({ - ...t, - color: theme.getAccentColors(t.id).base, - colorFade: theme.getAccentColors(t.id).fade3, - })); + return state.pf?.texts + .filter((t) => props.req.qck.txt?.includes(t.id)) + ?.map((t) => ({ + ...t, + color: theme.getAccentColors(t.id).base, + colorFade: theme.getAccentColors(t.id).fade3, + })); }); const settings = computed(() => [ ...(props.req.qck?.op?.toLowerCase() === 'and' ? [$t('search.settings.quick.defaultOperator')] : []), ...(props.req.qck?.re ? [$t('search.settings.quick.regexp')] : []), + ...(props.req.qck?.inh ? [$t('search.settings.quick.inheritedContents')] : []), + ...(props.req.qck?.allLvls ? [$t('search.settings.quick.allLevels')] : []), ...(props.req.gen?.strict ? [$t('search.settings.general.strict')] : []), ]); @@ -65,17 +65,20 @@ const settings = computed(() => [ {{ $t('general.in') }} - - - {{ text.title }} - + + {{ $t('search.results.allTexts') }} {{ $t('general.with') }} diff --git a/Tekst-Web/src/components/search/SearchResult.vue b/Tekst-Web/src/components/search/SearchResult.vue index 84cbdd69c..884361146 100644 --- a/Tekst-Web/src/components/search/SearchResult.vue +++ b/Tekst-Web/src/components/search/SearchResult.vue @@ -1,11 +1,21 @@ diff --git a/Tekst-Web/src/stores/search.ts b/Tekst-Web/src/stores/search.ts index 5936bd980..6f95c35ed 100644 --- a/Tekst-Web/src/stores/search.ts +++ b/Tekst-Web/src/stores/search.ts @@ -25,6 +25,8 @@ type GeneralSearchSettings = { type QuickSearchSettings = { op: 'OR' | 'AND'; re: boolean; + inh: boolean; + allLvls: boolean; txt?: string[]; }; @@ -55,6 +57,8 @@ const getDefaultSettings = () => ({ qck: { op: 'OR', re: false, + inh: false, + allLvls: false, } as QuickSearchSettings, adv: {} as AdvancedSearchSettings, }); @@ -71,6 +75,8 @@ export const useSearchStore = defineStore('search', () => { const router = useRouter(); const { message } = useMessages(); + const queryQuick = ref(''); + const queryAdvanced = ref([]); const settingsGeneral = ref(getDefaultSettings().gen); const settingsQuick = ref(getDefaultSettings().qck); const settingsAdvanced = ref(getDefaultSettings().adv); @@ -110,8 +116,10 @@ export const useSearchStore = defineStore('search', () => { settingsGeneral.value = decoded.gen || getDefaultSettings().gen; settingsGeneral.value.pgn = settingsGeneral.value.pgn || getDefaultSettings().gen.pgn; if (decoded.type === 'quick') { + queryQuick.value = decoded.q; settingsQuick.value = decoded.qck || getDefaultSettings().qck; } else if (decoded.type === 'advanced') { + queryAdvanced.value = decoded.q; settingsAdvanced.value = decoded.adv || getDefaultSettings().adv; } else { return DEFAULT_SEARCH_REQUEST_BODY; @@ -267,6 +275,8 @@ export const useSearchStore = defineStore('search', () => { ); return { + queryQuick, + queryAdvanced, settingsGeneral, settingsQuick, settingsAdvanced, diff --git a/Tekst-Web/src/views/SearchResultsView.vue b/Tekst-Web/src/views/SearchResultsView.vue index 69354bde9..6d12cab03 100644 --- a/Tekst-Web/src/views/SearchResultsView.vue +++ b/Tekst-Web/src/views/SearchResultsView.vue @@ -10,14 +10,13 @@ import { useMessages } from '@/composables/messages'; import { useTasks } from '@/composables/tasks'; import { $t } from '@/i18n'; import { DownloadIcon, ErrorIcon, NothingFoundIcon, SearchResultsIcon } from '@/icons'; -import { useResourcesStore, useSearchStore, useStateStore, useThemeStore } from '@/stores'; +import { useSearchStore, useStateStore, useThemeStore } from '@/stores'; import { isInputFocused, isOverlayOpen, pickTranslation, utcToLocalTime } from '@/utils'; import { createReusableTemplate, useMagicKeys, whenever } from '@vueuse/core'; import { NButton, NFlex, NIcon, NList, NPagination, NSpin, NTime } from 'naive-ui'; import { computed, onBeforeMount, ref } from 'vue'; const state = useStateStore(); -const resources = useResourcesStore(); const search = useSearchStore(); const theme = useThemeStore(); const [DefineTemplate, ReuseTemplate] = createReusableTemplate(); @@ -51,7 +50,6 @@ const results = computed( : undefined, highlight: r.highlight, smallScreen: state.smallScreen, - resourceTitles: resources.resourceTitles, }; }) || [] ); diff --git a/Tekst-Web/src/views/SearchView.vue b/Tekst-Web/src/views/SearchView.vue index 5e6a7d3c3..3d6ff0f02 100644 --- a/Tekst-Web/src/views/SearchView.vue +++ b/Tekst-Web/src/views/SearchView.vue @@ -223,11 +223,7 @@ whenever(ctrlEnter, () => { :style="{ backgroundColor: resourceColors[query.cmn.res].colors.fade3 }" > - + {