Class: HashtagAutocompleteService

Inherits:
Object
  • Object
show all
Defined in:
app/services/hashtag_autocomplete_service.rb

Defined Under Namespace

Classes: HashtagItem

Constant Summary collapse

HASHTAGS_PER_REQUEST =
20
SEARCH_MAX_LIMIT =
50
DEFAULT_DATA_SOURCES =
[CategoryHashtagDataSource, TagHashtagDataSource]
DEFAULT_CONTEXTUAL_TYPE_PRIORITIES =
[
  { type: "category", context: "topic-composer", priority: 100 },
  { type: "tag", context: "topic-composer", priority: 50 },
]

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(guardian) ⇒ HashtagAutocompleteService

Returns a new instance of HashtagAutocompleteService.



129
130
131
# File 'app/services/hashtag_autocomplete_service.rb', line 129

def initialize(guardian)
  @guardian = guardian
end

Instance Attribute Details

#guardianObject (readonly)

Returns the value of attribute guardian.



16
17
18
# File 'app/services/hashtag_autocomplete_service.rb', line 16

def guardian
  @guardian
end

Class Method Details

.contexts_with_ordered_typesObject



66
67
68
# File 'app/services/hashtag_autocomplete_service.rb', line 66

def self.contexts_with_ordered_types
  Hash[unique_contexts.map { |context| [context, ordered_types_for_context(context)] }]
end

.contextual_type_prioritiesObject



26
27
28
29
30
31
32
33
# File 'app/services/hashtag_autocomplete_service.rb', line 26

def self.contextual_type_priorities
  # Category and Tag type priorities for the composer are default and
  # always are included.
  Set.new(
    DEFAULT_CONTEXTUAL_TYPE_PRIORITIES |
      DiscoursePluginRegistry.hashtag_autocomplete_contextual_type_priorities,
  )
end

.data_source_from_type(type) ⇒ Object



47
48
49
# File 'app/services/hashtag_autocomplete_service.rb', line 47

def self.data_source_from_type(type)
  self.enabled_data_sources.find { |ds| ds.type == type }
end

.data_source_icon_mapObject



43
44
45
# File 'app/services/hashtag_autocomplete_service.rb', line 43

def self.data_source_icon_map
  self.enabled_data_sources.map { |ds| [ds.type, ds.icon] }.to_h
end

.data_source_typesObject



39
40
41
# File 'app/services/hashtag_autocomplete_service.rb', line 39

def self.data_source_types
  self.enabled_data_sources.map(&:type)
end

.data_sourcesObject

NOTE: This is not meant to be called directly; use ‘enabled_data_sources` or the individual data_source_X methods instead.



20
21
22
23
24
# File 'app/services/hashtag_autocomplete_service.rb', line 20

def self.data_sources
  # Category and Tag data sources are in core and always should be
  # included for searches and lookups.
  Set.new(DEFAULT_DATA_SOURCES | DiscoursePluginRegistry.hashtag_autocomplete_data_sources)
end

.enabled_data_sourcesObject



35
36
37
# File 'app/services/hashtag_autocomplete_service.rb', line 35

def self.enabled_data_sources
  self.data_sources.filter(&:enabled?)
end

.find_priorities_for_context(context) ⇒ Object



51
52
53
# File 'app/services/hashtag_autocomplete_service.rb', line 51

def self.find_priorities_for_context(context)
  contextual_type_priorities.select { |ctp| ctp[:context] == context }
end

.ordered_types_for_context(context) ⇒ Object



59
60
61
62
63
64
# File 'app/services/hashtag_autocomplete_service.rb', line 59

def self.ordered_types_for_context(context)
  find_priorities_for_context(context)
    .sort_by { |ctp| -ctp[:priority] }
    .map { |ctp| ctp[:type] }
    .reject { |type| data_source_types.exclude?(type) }
end

.search_conditionsObject



12
13
14
# File 'app/services/hashtag_autocomplete_service.rb', line 12

def self.search_conditions
  @search_conditions ||= Enum.new(contains: 0, starts_with: 1)
end

.unique_contextsObject



55
56
57
# File 'app/services/hashtag_autocomplete_service.rb', line 55

def self.unique_contexts
  contextual_type_priorities.map { |ctp| ctp[:context] }.uniq
end

Instance Method Details

#lookup(slugs, types_in_priority_order) ⇒ Object

Finds resources of the provided types by their exact slugs, unlike search which can search partial names, slugs, etc. Used for cooking fully formed #hashtags in the markdown pipeline. The @guardian handles permissions around which results should be returned here.

Parameters:

  • slugs (Array)

    The fully formed slugs to look up, which can have ::type suffixes attached as well (e.g. ::category), and in the case of categories can have parent:child relationships.

  • types_in_priority_order (Array)

    The resource types we are looking up and the priority order in which we should match them if they do not have type suffixes.

Raises:



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'app/services/hashtag_autocomplete_service.rb', line 148

def lookup(slugs, types_in_priority_order)
  raise Discourse::InvalidParameters.new(:slugs) if !slugs.is_a?(Array)
  raise Discourse::InvalidParameters.new(:order) if !types_in_priority_order.is_a?(Array)

  types_in_priority_order =
    types_in_priority_order.select do |type|
      HashtagAutocompleteService.data_source_types.include?(type)
    end
  lookup_results = Hash[types_in_priority_order.collect { |type| [type.to_sym, []] }]
  limited_slugs = slugs[0..HashtagAutocompleteService::HASHTAGS_PER_REQUEST]

  slugs_without_suffixes =
    limited_slugs.reject do |slug|
      HashtagAutocompleteService.data_source_types.any? { |type| slug.ends_with?("::#{type}") }
    end
  slugs_with_suffixes = (limited_slugs - slugs_without_suffixes)

  # For all the slugs without a type suffix, we need to lookup in order, falling
  # back to the next type if no results are returned for a slug for the current
  # type. This way slugs without suffix make sense in context, e.g. in the topic
  # composer we want a slug without a suffix to be a category first, tag second.
  if slugs_without_suffixes.any?
    types_in_priority_order.each do |type|
      # We do not want to continue fallback if there are conflicting slugs where
      # one has a type and one does not, this may result in duplication. An
      # example:
      #
      # A category with slug `management` is not found because of permissions
      # and we also have a slug with suffix in the form of `management::tag`.
      # There is a tag that exists with the `management` slug. The tag should
      # not be found here but rather in the next lookup since it's got a more
      # specific lookup with the type.
      slugs_to_lookup =
        slugs_without_suffixes.reject { |slug| slugs_with_suffixes.include?("#{slug}::#{type}") }
      found_from_slugs = execute_lookup!(lookup_results, type, guardian, slugs_to_lookup)

      slugs_without_suffixes = slugs_without_suffixes - found_from_slugs.map(&:ref)
      break if slugs_without_suffixes.empty?
    end
  end

  # We then look up the remaining slugs based on their type suffix, stripping out
  # the type suffix first since it will not match the actual slug.
  if slugs_with_suffixes.any?
    types_in_priority_order.each do |type|
      slugs_for_type =
        slugs_with_suffixes
          .select { |slug| slug.ends_with?("::#{type}") }
          .map { |slug| slug.gsub("::#{type}", "") }
      next if slugs_for_type.empty?
      execute_lookup!(lookup_results, type, guardian, slugs_for_type)

      # Make sure the refs are the same going out as they were going in.
      lookup_results[type.to_sym].each do |item|
        item.ref = "#{item.ref}::#{type}" if slugs_with_suffixes.include?("#{item.ref}::#{type}")
      end
    end
  end

  lookup_results
end

#search(term, types_in_priority_order, limit: SiteSetting.experimental_hashtag_search_result_limit) ⇒ Object

Searches registered hashtag data sources using the provided term (data sources determine what is actually searched) and prioritises the results based on types_in_priority_order and the limit. For example, if 5 categories were returned for the term and the limit was 5, we would not even bother searching tags. The @guardian handles permissions around which results should be returned here.

Items which have a slug that exactly matches the search term via lookup will be found first and floated to the top of the results, and still be ordered by type.

Parameters:

  • term (String)

    Search term, from the UI generally where the user is typing #has…

  • types_in_priority_order (Array)

    The resource types we are searching for and the priority order in which we should return them.

  • limit (Integer) (defaults to: SiteSetting.experimental_hashtag_search_result_limit)

    The maximum number of search results to return, we don’t bother searching subsequent types if the first types in the array already reach the limit.

Raises:



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'app/services/hashtag_autocomplete_service.rb', line 229

def search(
  term,
  types_in_priority_order,
  limit: SiteSetting.experimental_hashtag_search_result_limit
)
  raise Discourse::InvalidParameters.new(:order) if !types_in_priority_order.is_a?(Array)
  limit = [limit, SEARCH_MAX_LIMIT].min
  types_in_priority_order =
    types_in_priority_order.select do |type|
      HashtagAutocompleteService.data_source_types.include?(type)
    end

  return search_without_term(types_in_priority_order, limit) if term.blank?

  limited_results = []
  top_ranked_type = nil
  term = term.downcase

  # Float exact matches by slug to the top of the list, any of these will be excluded
  # from further results.
  types_in_priority_order.each do |type|
    search_results = execute_lookup!(nil, type, guardian, [term])
    limited_results.concat(search_results) if search_results
    break if limited_results.length >= limit
  end

  # Next priority are slugs which start with the search term.
  if limited_results.length < limit
    types_in_priority_order.each do |type|
      limited_results =
        search_using_condition(
          limited_results,
          term,
          type,
          limit,
          HashtagAutocompleteService.search_conditions[:starts_with],
        )
      top_ranked_type = type if top_ranked_type.nil?
      break if limited_results.length >= limit
    end
  end

  # Search the data source for each type, validate and sort results,
  # and break off from searching more data sources if we reach our limit
  if limited_results.length < limit
    types_in_priority_order.each do |type|
      limited_results =
        search_using_condition(
          limited_results,
          term,
          type,
          limit,
          HashtagAutocompleteService.search_conditions[:contains],
        )
      top_ranked_type = type if top_ranked_type.nil?
      break if limited_results.length >= limit
    end
  end

  # Any items that are _not_ the top-ranked type (which could possibly not be
  # the same as the first item in the types_in_priority_order if there was
  # no data for that type) that have conflicting slugs with other items for
  # other higher-ranked types need to have a ::type suffix added to their ref.
  #
  # This will be used for the lookup method above if one of these items is
  # chosen in the UI, otherwise there is no way to determine whether a hashtag is
  # for a category or a tag etc.
  #
  # For example, if there is a category with the slug #general and a tag
  # with the slug #general, then the tag will have its ref changed to #general::tag
  append_types_to_conflicts(limited_results, top_ranked_type, types_in_priority_order, limit)
end