diff --git a/silk-core/src/main/scala/org/silkframework/config/TaskSpec.scala b/silk-core/src/main/scala/org/silkframework/config/TaskSpec.scala index 4b03ec9be1..6483c16a01 100644 --- a/silk-core/src/main/scala/org/silkframework/config/TaskSpec.scala +++ b/silk-core/src/main/scala/org/silkframework/config/TaskSpec.scala @@ -89,6 +89,11 @@ trait TaskSpec { /** Additional tags that will be displayed in the UI for this task. These tags are covered by the workspace search. */ def searchTags(pluginContext: PluginContext): Seq[String] = Seq.empty + + /** Additional search strings that will be covered by the workspace search, but NOT shown in the UI. + * Because they are hidden, they only count as a search match when a search term is exactly equal to one of them + * (case-insensitive); substring matches are not considered. This should be used rarely, since it could lead to confusing UX. */ + def hiddenSearchTags(pluginContext: PluginContext): Seq[String] = Seq.empty } /** A task link. diff --git a/silk-core/src/main/scala/org/silkframework/dataset/Dataset.scala b/silk-core/src/main/scala/org/silkframework/dataset/Dataset.scala index 1363bfb239..1f59e2cf30 100644 --- a/silk-core/src/main/scala/org/silkframework/dataset/Dataset.scala +++ b/silk-core/src/main/scala/org/silkframework/dataset/Dataset.scala @@ -31,6 +31,11 @@ trait Dataset extends AnyPlugin with DatasetAccess { /** Additional tags that will be displayed in the UI for this task. These tags are covered by the workspace search. */ def searchTags(pluginContext: PluginContext): Seq[String] = Seq.empty + + /** Additional search strings that will be covered by the workspace search, but NOT shown in the UI. + * Because they are hidden, they only count as a search match when a search term is exactly equal to one of them + * (case-insensitive); substring matches are not considered. This should be used rarely, since it could lead to confusing UX. */ + def hiddenSearchTags(pluginContext: PluginContext): Seq[String] = Seq.empty } trait DatasetPluginAutoConfigurable[T <: Dataset] { diff --git a/silk-core/src/main/scala/org/silkframework/dataset/DatasetSpec.scala b/silk-core/src/main/scala/org/silkframework/dataset/DatasetSpec.scala index c77f0fd6a6..4f008630f5 100644 --- a/silk-core/src/main/scala/org/silkframework/dataset/DatasetSpec.scala +++ b/silk-core/src/main/scala/org/silkframework/dataset/DatasetSpec.scala @@ -111,6 +111,10 @@ case class DatasetSpec[+DatasetType <: Dataset](plugin: DatasetType, override def searchTags(pluginContext: PluginContext): Seq[String] = { plugin.searchTags(pluginContext) } + + override def hiddenSearchTags(pluginContext: PluginContext): Seq[String] = { + plugin.hiddenSearchTags(pluginContext) + } } case class DatasetTask(id: Identifier, data: DatasetSpec[Dataset], metaData: MetaData = MetaData.empty) extends Task[DatasetSpec[Dataset]] { diff --git a/silk-workbench/silk-workbench-core/app/controllers/util/TextSearchUtils.scala b/silk-workbench/silk-workbench-core/app/controllers/util/TextSearchUtils.scala index ef7e99fbfb..fffc8577dc 100644 --- a/silk-workbench/silk-workbench-core/app/controllers/util/TextSearchUtils.scala +++ b/silk-workbench/silk-workbench-core/app/controllers/util/TextSearchUtils.scala @@ -11,7 +11,16 @@ object TextSearchUtils { /** Match search terms against string. Returns only true if all search terms match at least one of the provided strings. */ def matchesSearchTerm(lowerCaseSearchTerms: Iterable[String], searchIn: String*): Boolean = { + matchesSearchTerm(lowerCaseSearchTerms, searchIn, Iterable.empty) + } + + /** Match search terms against strings. + * Returns true if every search term either occurs as a substring in one of the `searchIn` values + * or is exactly equal (case-insensitive) to one of the `exactMatchSearchIn` values. + */ + def matchesSearchTerm(lowerCaseSearchTerms: Iterable[String], searchIn: Iterable[String], exactMatchSearchIn: Iterable[String]): Boolean = { val lowerCaseTexts = searchIn.map(_.toLowerCase) - lowerCaseSearchTerms forall (searchTerm => lowerCaseTexts.exists(_.contains(searchTerm))) + val lowerCaseExactMatches = exactMatchSearchIn.map(_.toLowerCase).toSet + lowerCaseSearchTerms forall (searchTerm => lowerCaseTexts.exists(_.contains(searchTerm)) || lowerCaseExactMatches.contains(searchTerm)) } } \ No newline at end of file diff --git a/silk-workbench/silk-workbench-core/test/controllers/util/TextSearchUtilsTest.scala b/silk-workbench/silk-workbench-core/test/controllers/util/TextSearchUtilsTest.scala new file mode 100644 index 0000000000..5e7fa13d2f --- /dev/null +++ b/silk-workbench/silk-workbench-core/test/controllers/util/TextSearchUtilsTest.scala @@ -0,0 +1,51 @@ +package controllers.util + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.must.Matchers + +class TextSearchUtilsTest extends AnyFlatSpec with Matchers { + behavior of "TextSearchUtils.matchesSearchTerm" + + private def terms(s: String): Array[String] = TextSearchUtils.extractSearchTerms(s) + + it should "match search terms via substring against searchIn (legacy varargs overload)" in { + TextSearchUtils.matchesSearchTerm(terms("foo"), "Hello FooBar World") mustBe true + TextSearchUtils.matchesSearchTerm(terms("foo bar"), "fooworld", "barworld") mustBe true + TextSearchUtils.matchesSearchTerm(terms("missing"), "Hello FooBar World") mustBe false + } + + it should "match a search term that is exactly equal to an exact-match value (case-insensitive)" in { + TextSearchUtils.matchesSearchTerm(terms("secret"), Seq("Hello World"), Seq("secret")) mustBe true + TextSearchUtils.matchesSearchTerm(terms("Secret"), Seq("Hello World"), Seq("SECRET")) mustBe true + } + + it should "NOT match a search term that is only a substring of an exact-match value" in { + TextSearchUtils.matchesSearchTerm(terms("sec"), Seq("Hello World"), Seq("secret")) mustBe false + TextSearchUtils.matchesSearchTerm(terms("ret"), Seq("Hello World"), Seq("secret")) mustBe false + } + + it should "NOT match if a search term contains an exact-match value but is longer" in { + TextSearchUtils.matchesSearchTerm(terms("secrets"), Seq("Hello World"), Seq("secret")) mustBe false + } + + it should "satisfy a search term via either substring searchIn or exact-match searchIn" in { + TextSearchUtils.matchesSearchTerm(terms("foo secret"), Seq("HelloFooBar"), Seq("secret")) mustBe true + TextSearchUtils.matchesSearchTerm(terms("foo secret"), Seq("HelloFooBar"), Seq("sec")) mustBe false + TextSearchUtils.matchesSearchTerm(terms("foo missing"), Seq("HelloFooBar"), Seq("secret")) mustBe false + } + + it should "require all search terms to be satisfied" in { + TextSearchUtils.matchesSearchTerm(terms("alpha beta"), Seq("alphabet"), Seq("beta")) mustBe true + TextSearchUtils.matchesSearchTerm(terms("alpha gamma"), Seq("alphabet"), Seq("beta")) mustBe false + } + + it should "treat empty exactMatchSearchIn as the legacy substring-only behavior" in { + TextSearchUtils.matchesSearchTerm(terms("foo"), Seq("HelloFooBar"), Iterable.empty) mustBe true + TextSearchUtils.matchesSearchTerm(terms("missing"), Seq("HelloFooBar"), Iterable.empty) mustBe false + } + + it should "return true for an empty search-term list" in { + TextSearchUtils.matchesSearchTerm(terms(""), Seq("anything"), Seq("anything")) mustBe true + TextSearchUtils.matchesSearchTerm(terms(" "), Seq("anything"), Seq("anything")) mustBe true + } +} \ No newline at end of file diff --git a/silk-workbench/silk-workbench-workspace/app/controllers/projectApi/ProjectTaskApi.scala b/silk-workbench/silk-workbench-workspace/app/controllers/projectApi/ProjectTaskApi.scala index 9e1b7c1f9e..34002d0df8 100644 --- a/silk-workbench/silk-workbench-workspace/app/controllers/projectApi/ProjectTaskApi.scala +++ b/silk-workbench/silk-workbench-workspace/app/controllers/projectApi/ProjectTaskApi.scala @@ -131,7 +131,7 @@ class ProjectTaskApi @Inject()() extends InjectedController with UserContextActi val task = project.anyTask(taskId) val relatedTasks = (task.data.referencedTasks.toSeq ++ task.findDependentTasks(recursive = false).toSeq ++ task.findRelatedTasksInsideWorkflows().toSeq).distinct. flatMap(id => project.anyTaskOption(id)) - val relatedItems = relatedTasks map { task => + val relatedItemsWithHiddenTags = relatedTasks map { task => val pd = PluginDescription.forTask(task) val itemType = ItemType.itemType(task) val itemLinks = ItemType.itemTypeLinks(itemType, projectId, task.id, Some(task.data)) @@ -143,15 +143,17 @@ class ProjectTaskApi @Inject()() extends InjectedController with UserContextActi None } } - RelatedItem(task.id, task.fullLabel, task.metaData.description, itemType.label, itemLinks, pd.label, task.tags().map(FullTag.fromTag), - task.searchTags(PluginContext.fromProject(project)), + val pluginContext = PluginContext.fromProject(project) + val item = RelatedItem(task.id, task.fullLabel, task.metaData.description, itemType.label, itemLinks, pd.label, task.tags().map(FullTag.fromTag), + task.searchTags(pluginContext), Some(pd.id), Some(project.id), readOnly ) + (item, task.hiddenSearchTags(pluginContext)) } - val filteredItems = filterRelatedItems(relatedItems, textQuery) - val total = relatedItems.size + val filteredItems = filterRelatedItems(relatedItemsWithHiddenTags, textQuery) + val total = relatedItemsWithHiddenTags.size val result = RelatedItems(total, filteredItems) Ok(Json.toJson(result)) } @@ -429,17 +431,18 @@ class ProjectTaskApi @Inject()() extends InjectedController with UserContextActi } } - private def filterRelatedItems(relatedItems: Seq[RelatedItem], textQuery: Option[String]): Seq[RelatedItem] = { + private def filterRelatedItems(relatedItems: Seq[(RelatedItem, Seq[String])], textQuery: Option[String]): Seq[RelatedItem] = { val searchWords = TextSearchUtils.extractSearchTerms(textQuery.getOrElse("")) if(searchWords.isEmpty) { - relatedItems + relatedItems.map(_._1) } else { - relatedItems.filter(relatedItem => // Description is not displayed, so don't search in description. - TextSearchUtils.matchesSearchTerm( + relatedItems.collect { case (relatedItem, hiddenSearchTags) // Description is not displayed, so don't search in description. + if TextSearchUtils.matchesSearchTerm( searchWords, - s"${relatedItem.label} ${relatedItem.`type`} ${relatedItem.pluginLabel} ${relatedItem.tags.map(_.label).mkString(" ")} ${relatedItem.searchTags.mkString(" ")}".toLowerCase - ) - ) + Seq(s"${relatedItem.label} ${relatedItem.`type`} ${relatedItem.pluginLabel} ${relatedItem.tags.map(_.label).mkString(" ")} ${relatedItem.searchTags.mkString(" ")}".toLowerCase), + hiddenSearchTags + ) => relatedItem + } } } } diff --git a/silk-workbench/silk-workbench-workspace/app/controllers/workspaceApi/search/SearchApiModel.scala b/silk-workbench/silk-workbench-workspace/app/controllers/workspaceApi/search/SearchApiModel.scala index 0b0c9311bf..636ff05093 100644 --- a/silk-workbench/silk-workbench-workspace/app/controllers/workspaceApi/search/SearchApiModel.scala +++ b/silk-workbench/silk-workbench-workspace/app/controllers/workspaceApi/search/SearchApiModel.scala @@ -171,8 +171,9 @@ object SearchApiModel { val searchInItemType = if(task.data.isInstanceOf[DatasetSpec[_]]) "dataset" else "" val tagLabels = task.tags().map(_.label) val searchTags = task.searchTags(pluginContext) + val hiddenSearchTags = task.hiddenSearchTags(pluginContext) val searchInTerms = Seq(taskLabel, description, searchInProperties, searchInProject, pluginLabel, searchInItemType) ++ tagLabels ++ searchTags - matchesSearchTerm(lowerCaseSearchTerms, searchInTerms: _*) + TextSearchUtils.matchesSearchTerm(lowerCaseSearchTerms, searchInTerms, hiddenSearchTags) } /** Match search terms against project. */ diff --git a/silk-workbench/silk-workbench-workspace/test/controllers/workspace/SearchApiIntegrationTest.scala b/silk-workbench/silk-workbench-workspace/test/controllers/workspace/SearchApiIntegrationTest.scala index 292f8ad429..d954632da4 100644 --- a/silk-workbench/silk-workbench-workspace/test/controllers/workspace/SearchApiIntegrationTest.scala +++ b/silk-workbench/silk-workbench-workspace/test/controllers/workspace/SearchApiIntegrationTest.scala @@ -5,8 +5,8 @@ import controllers.util.ItemType import controllers.workspaceApi.search.SearchApiModel.{DESCRIPTION, FacetSetting, FacetType, FacetedSearchRequest, FacetedSearchResult, Facets, ID, KeywordFacetSetting, LABEL, PARAMETERS, PLUGIN_ID, PLUGIN_LABEL, PROJECT_ID, PROJECT_LABEL, SortBy, SortOrder, SortableProperty} import controllers.workspaceApi.search._ import helper.IntegrationTestTrait -import org.silkframework.config.MetaData -import org.silkframework.runtime.plugin.{AutoCompletionResult, PluginRegistry} +import org.silkframework.config.{CustomTask, InputPorts, MetaData, Port} +import org.silkframework.runtime.plugin.{AutoCompletionResult, PluginContext, PluginRegistry} import org.silkframework.workspace.activity.workflow.Workflow import org.silkframework.workspace.{SingleProjectWorkspaceProviderTestTrait, WorkspaceFactory} import play.api.libs.json._ @@ -301,6 +301,30 @@ class SearchApiIntegrationTest extends AnyFlatSpec (results.head \ ID).as[String] mustBe "jsonXYZ" } + it should "match hidden search tags only on exact (case-insensitive) terms while still matching regular search tags as substrings" in { + PluginRegistry.registerPlugin(classOf[HiddenSearchTagsTestTask]) + val newProject = "hiddenSearchTagsProject" + val taskId = "hiddenSearchTagsTask" + val project = retrieveOrCreateProject(newProject) + try { + project.addTask(taskId, HiddenSearchTagsTestTask()) + def search(query: String): Seq[String] = resultItemIds(facetedSearchRequest( + FacetedSearchRequest(project = Some(newProject), textQuery = Some(query)) + )._1) + + // Regular searchTag still matches as substring (regression) + search(HiddenSearchTagsTestTask.visibleTagSubstring) must contain(taskId) + // Hidden tag matches when search term equals it exactly + search(HiddenSearchTagsTestTask.hiddenTag) must contain(taskId) + // Case-insensitive on the hidden-tag side + search(HiddenSearchTagsTestTask.hiddenTag.toUpperCase) must contain(taskId) + // A substring of the hidden tag does NOT match + search(HiddenSearchTagsTestTask.hiddenTagSubstring) must not contain taskId + } finally { + WorkspaceFactory().workspace.removeProject(newProject) + } + } + private val testAutoCompletionProvider = TestAutoCompletionProvider() implicit private val autoCompleteResultReads: Writes[AutoCompletionResult] = Json.writes[AutoCompletionResult] @@ -371,3 +395,17 @@ class SearchApiIntegrationTest extends AnyFlatSpec map(k => (k.id, k.count.get)) } } + +object HiddenSearchTagsTestTask { + val visibleTag: String = "visibleTagAbcDef" + val visibleTagSubstring: String = "AbcD" + val hiddenTag: String = "hiddenTagXyz" + val hiddenTagSubstring: String = "TagX" +} + +case class HiddenSearchTagsTestTask() extends CustomTask { + override def inputPorts: InputPorts = InputPorts.NoInputPorts + override def outputPort: Option[Port] = None + override def searchTags(pluginContext: PluginContext): Seq[String] = Seq(HiddenSearchTagsTestTask.visibleTag) + override def hiddenSearchTags(pluginContext: PluginContext): Seq[String] = Seq(HiddenSearchTagsTestTask.hiddenTag) +}