Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
}
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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)
}
Loading