FEATURE: Generic hashtag autocomplete part 1 (#18592)
This commit adds a new `/hashtag/search` endpoint and both relevant JS and ruby plugin APIs to handle plugins adding their own data sources and priority orders for types of things to search when `#` is pressed. A `context` param is added to `setupHashtagAutocomplete` which a corresponding chat PR https://github.com/discourse/discourse-chat/pull/1302 will now use. The UI calls `registerHashtagSearchParam` for each context that will require a `#` search (e.g. the topic composer), for each type of record that the context needs to search for, as well as a priority order for that type. Core uses this call to add the `category` and `tag` data sources to the topic composer. The `register_hashtag_data_source` ruby plugin API call is for plugins to add a new data source for the hashtag searching endpoint, e.g. discourse-chat may add a `channel` data source. This functionality is hidden behind the `enable_experimental_hashtag_autocomplete` flag, except for the change to `setupHashtagAutocomplete` since only core and discourse-chat are using that function. Note this PR does **not** include required changes for hashtag lookup or new styling.
This commit is contained in:
parent
45bdfa1c84
commit
7c25597da2
|
@ -462,10 +462,15 @@ export default Component.extend(TextareaTextManipulation, {
|
||||||
},
|
},
|
||||||
|
|
||||||
_applyCategoryHashtagAutocomplete() {
|
_applyCategoryHashtagAutocomplete() {
|
||||||
setupHashtagAutocomplete(this._$textarea, this.siteSettings, (value) => {
|
setupHashtagAutocomplete(
|
||||||
this.set("value", value);
|
"topic-composer",
|
||||||
schedule("afterRender", this, this.focusTextArea);
|
this._$textarea,
|
||||||
});
|
this.siteSettings,
|
||||||
|
(value) => {
|
||||||
|
this.set("value", value);
|
||||||
|
schedule("afterRender", this, this.focusTextArea);
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
_applyEmojiAutocomplete($textarea) {
|
_applyEmojiAutocomplete($textarea) {
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "composer-hashtag-autocomplete",
|
||||||
|
|
||||||
|
initialize(container) {
|
||||||
|
const siteSettings = container.lookup("service:site-settings");
|
||||||
|
|
||||||
|
withPluginApi("1.4.0", (api) => {
|
||||||
|
if (siteSettings.enable_experimental_hashtag_autocomplete) {
|
||||||
|
api.registerHashtagSearchParam("category", "topic-composer", 100);
|
||||||
|
api.registerHashtagSearchParam("tag", "topic-composer", 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -1,9 +1,7 @@
|
||||||
|
import { hashtagTriggerRule } from "discourse/lib/hashtag-autocomplete";
|
||||||
|
import deprecated from "discourse-common/lib/deprecated";
|
||||||
|
|
||||||
export const SEPARATOR = ":";
|
export const SEPARATOR = ":";
|
||||||
import {
|
|
||||||
caretPosition,
|
|
||||||
caretRowCol,
|
|
||||||
inCodeBlock,
|
|
||||||
} from "discourse/lib/utilities";
|
|
||||||
|
|
||||||
export function replaceSpan($elem, categorySlug, categoryLink, type) {
|
export function replaceSpan($elem, categorySlug, categoryLink, type) {
|
||||||
type = type ? ` data-type="${type}"` : "";
|
type = type ? ` data-type="${type}"` : "";
|
||||||
|
@ -13,29 +11,12 @@ export function replaceSpan($elem, categorySlug, categoryLink, type) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function categoryHashtagTriggerRule(textarea, opts) {
|
export function categoryHashtagTriggerRule(textarea, opts) {
|
||||||
const result = caretRowCol(textarea);
|
deprecated(
|
||||||
const row = result.rowNum;
|
"categoryHashtagTriggerRule is being replaced by hashtagTriggerRule and the new hashtag-autocomplete plugin APIs",
|
||||||
let col = result.colNum;
|
{
|
||||||
let line = textarea.value.split("\n")[row - 1];
|
since: "2.9.0.beta10",
|
||||||
|
dropFrom: "3.0.0.beta1",
|
||||||
if (opts && opts.backSpace) {
|
|
||||||
col = col - 1;
|
|
||||||
line = line.slice(0, line.length - 1);
|
|
||||||
|
|
||||||
// Don't trigger autocomplete when backspacing into a `#category |` => `#category|`
|
|
||||||
if (/^#{1}\w+/.test(line)) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
return hashtagTriggerRule(textarea, opts);
|
||||||
// Don't trigger autocomplete when ATX-style headers are used
|
|
||||||
if (col < 6 && line.slice(0, col) === "#".repeat(col)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inCodeBlock(textarea.value, caretPosition(textarea))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,81 @@
|
||||||
import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
||||||
|
import discourseLater from "discourse-common/lib/later";
|
||||||
// TODO: (martin) Make a more generic version of these functions.
|
import { INPUT_DELAY, isTesting } from "discourse-common/config/environment";
|
||||||
import { categoryHashtagTriggerRule } from "discourse/lib/category-hashtags";
|
import { cancel } from "@ember/runloop";
|
||||||
|
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import discourseDebounce from "discourse-common/lib/debounce";
|
||||||
|
import {
|
||||||
|
caretPosition,
|
||||||
|
caretRowCol,
|
||||||
|
inCodeBlock,
|
||||||
|
} from "discourse/lib/utilities";
|
||||||
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
|
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
|
||||||
|
|
||||||
export function setupHashtagAutocomplete(
|
export function setupHashtagAutocomplete(
|
||||||
|
context,
|
||||||
$textArea,
|
$textArea,
|
||||||
siteSettings,
|
siteSettings,
|
||||||
afterComplete
|
afterComplete
|
||||||
) {
|
) {
|
||||||
if (siteSettings.enable_experimental_hashtag_autocomplete) {
|
if (siteSettings.enable_experimental_hashtag_autocomplete) {
|
||||||
_setupExperimental($textArea, siteSettings, afterComplete);
|
_setupExperimental(context, $textArea, siteSettings, afterComplete);
|
||||||
} else {
|
} else {
|
||||||
_setup($textArea, siteSettings, afterComplete);
|
_setup($textArea, siteSettings, afterComplete);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _setupExperimental($textArea, siteSettings, afterComplete) {
|
const contextBasedParams = {};
|
||||||
|
|
||||||
|
export function registerHashtagSearchParam(param, context, priority) {
|
||||||
|
if (!contextBasedParams[context]) {
|
||||||
|
contextBasedParams[context] = {};
|
||||||
|
}
|
||||||
|
contextBasedParams[context][param] = priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashtagTriggerRule(textarea, opts) {
|
||||||
|
const result = caretRowCol(textarea);
|
||||||
|
const row = result.rowNum;
|
||||||
|
let col = result.colNum;
|
||||||
|
let line = textarea.value.split("\n")[row - 1];
|
||||||
|
|
||||||
|
if (opts && opts.backSpace) {
|
||||||
|
col = col - 1;
|
||||||
|
line = line.slice(0, line.length - 1);
|
||||||
|
|
||||||
|
// Don't trigger autocomplete when backspacing into a `#category |` => `#category|`
|
||||||
|
if (/^#{1}\w+/.test(line)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't trigger autocomplete when ATX-style headers are used
|
||||||
|
if (col < 6 && line.slice(0, col) === "#".repeat(col)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock(textarea.value, caretPosition(textarea))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setupExperimental(context, $textArea, siteSettings, afterComplete) {
|
||||||
$textArea.autocomplete({
|
$textArea.autocomplete({
|
||||||
template: findRawTemplate("hashtag-autocomplete"),
|
template: findRawTemplate("hashtag-autocomplete"),
|
||||||
key: "#",
|
key: "#",
|
||||||
afterComplete,
|
afterComplete,
|
||||||
treatAsTextarea: $textArea[0].tagName === "INPUT",
|
treatAsTextarea: $textArea[0].tagName === "INPUT",
|
||||||
transformComplete: (obj) => {
|
transformComplete: (obj) => obj.ref,
|
||||||
return obj.text;
|
|
||||||
},
|
|
||||||
dataSource: (term) => {
|
dataSource: (term) => {
|
||||||
if (term.match(/\s/)) {
|
if (term.match(/\s/)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return searchCategoryTag(term, siteSettings);
|
return _searchGeneric(term, siteSettings, context);
|
||||||
},
|
|
||||||
triggerRule: (textarea, opts) => {
|
|
||||||
return categoryHashtagTriggerRule(textarea, opts);
|
|
||||||
},
|
},
|
||||||
|
triggerRule: (textarea, opts) => hashtagTriggerRule(textarea, opts),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,17 +84,78 @@ function _setup($textArea, siteSettings, afterComplete) {
|
||||||
template: findRawTemplate("category-tag-autocomplete"),
|
template: findRawTemplate("category-tag-autocomplete"),
|
||||||
key: "#",
|
key: "#",
|
||||||
afterComplete,
|
afterComplete,
|
||||||
transformComplete: (obj) => {
|
transformComplete: (obj) => obj.text,
|
||||||
return obj.text;
|
|
||||||
},
|
|
||||||
dataSource: (term) => {
|
dataSource: (term) => {
|
||||||
if (term.match(/\s/)) {
|
if (term.match(/\s/)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return searchCategoryTag(term, siteSettings);
|
return searchCategoryTag(term, siteSettings);
|
||||||
},
|
},
|
||||||
triggerRule: (textarea, opts) => {
|
triggerRule: (textarea, opts) => hashtagTriggerRule(textarea, opts),
|
||||||
return categoryHashtagTriggerRule(textarea, opts);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let searchCache = {};
|
||||||
|
let searchCacheTime;
|
||||||
|
let currentSearch;
|
||||||
|
|
||||||
|
function _updateSearchCache(term, results) {
|
||||||
|
searchCache[term] = results;
|
||||||
|
searchCacheTime = new Date();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _searchGeneric(term, siteSettings, context) {
|
||||||
|
if (currentSearch) {
|
||||||
|
currentSearch.abort();
|
||||||
|
currentSearch = null;
|
||||||
|
}
|
||||||
|
if (new Date() - searchCacheTime > 30000) {
|
||||||
|
searchCache = {};
|
||||||
|
}
|
||||||
|
const cached = searchCache[term];
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let timeoutPromise = isTesting()
|
||||||
|
? null
|
||||||
|
: discourseLater(() => {
|
||||||
|
resolve(CANCELLED_STATUS);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
if (term === "") {
|
||||||
|
return resolve(CANCELLED_STATUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSearch = (q, ctx, resultFunc) => {
|
||||||
|
discourseDebounce(this, _searchRequest, q, ctx, resultFunc, INPUT_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
debouncedSearch(term, context, (result) => {
|
||||||
|
cancel(timeoutPromise);
|
||||||
|
resolve(_updateSearchCache(term, result));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _searchRequest(term, context, resultFunc) {
|
||||||
|
currentSearch = ajax("/hashtags/search.json", {
|
||||||
|
data: { term, order: _sortedContextParams(context) },
|
||||||
|
});
|
||||||
|
currentSearch
|
||||||
|
.then((r) => {
|
||||||
|
resultFunc(r.results || CANCELLED_STATUS);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
currentSearch = null;
|
||||||
|
});
|
||||||
|
return currentSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sortedContextParams(context) {
|
||||||
|
return Object.entries(contextBasedParams[context])
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map((item) => item[0]);
|
||||||
|
}
|
||||||
|
|
|
@ -104,6 +104,7 @@ import DiscourseURL from "discourse/lib/url";
|
||||||
import { registerNotificationTypeRenderer } from "discourse/lib/notification-types-manager";
|
import { registerNotificationTypeRenderer } from "discourse/lib/notification-types-manager";
|
||||||
import { registerUserMenuTab } from "discourse/lib/user-menu/tab";
|
import { registerUserMenuTab } from "discourse/lib/user-menu/tab";
|
||||||
import { registerModelTransformer } from "discourse/lib/model-transformers";
|
import { registerModelTransformer } from "discourse/lib/model-transformers";
|
||||||
|
import { registerHashtagSearchParam } from "discourse/lib/hashtag-autocomplete";
|
||||||
|
|
||||||
// If you add any methods to the API ensure you bump up the version number
|
// If you add any methods to the API ensure you bump up the version number
|
||||||
// based on Semantic Versioning 2.0.0. Please update the changelog at
|
// based on Semantic Versioning 2.0.0. Please update the changelog at
|
||||||
|
@ -1981,6 +1982,35 @@ class PluginApi {
|
||||||
registerModelTransformer(modelName, transformer) {
|
registerModelTransformer(modelName, transformer) {
|
||||||
registerModelTransformer(modelName, transformer);
|
registerModelTransformer(modelName, transformer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPERIMENTAL. Do not use.
|
||||||
|
*
|
||||||
|
* When initiating a search inside the composer or other designated inputs
|
||||||
|
* with the `#` key, we search records based on params registered with
|
||||||
|
* this function, and order them by type using the priority here. Since
|
||||||
|
* there can be many different inputs that use `#` and some may need to
|
||||||
|
* weight different types higher in priority, we also require a context
|
||||||
|
* parameter.
|
||||||
|
*
|
||||||
|
* For example, the topic composer may wish to search for categories
|
||||||
|
* and tags, with categories appearing first in the results. The usage
|
||||||
|
* looks like this:
|
||||||
|
*
|
||||||
|
* api.registerHashtagSearchParam("category", "topic-composer", 100);
|
||||||
|
* api.registerHashtagSearchParam("tag", "topic-composer", 50);
|
||||||
|
*
|
||||||
|
* Additional types of records used for the hashtag search results
|
||||||
|
* can be registered via the #register_hashtag_data_source plugin API
|
||||||
|
* method.
|
||||||
|
*
|
||||||
|
* @param {string} param - The type of record to be fetched.
|
||||||
|
* @param {string} context - Where the hashtag search is being initiated using `#`
|
||||||
|
* @param {number} priority - Used for ordering types of records. Priority order is descending.
|
||||||
|
*/
|
||||||
|
registerHashtagSearchParam(param, context, priority) {
|
||||||
|
registerHashtagSearchParam(param, context, priority);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number
|
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
<div class='autocomplete hashtag-autocomplete'>
|
<div class='autocomplete hashtag-autocomplete'>
|
||||||
<ul>
|
<ul>
|
||||||
{{#each options as |option|}}
|
{{#each options as |option|}}
|
||||||
<li>
|
<li class="hashtag-autocomplete__option">
|
||||||
{{#if option.model}}
|
<a class="hashtag-autocomplete__link" href>{{d-icon option.icon}}<span class="hashtag-autocomplete__text">{{option.text}}</span></a>
|
||||||
<a href>{{category-link option.model allowUncategorized="true" link="false"}}</a>
|
|
||||||
{{else}}
|
|
||||||
<a href>{{d-icon 'tag'}}{{option.name}} x {{option.count}}</a>
|
|
||||||
{{/if}}
|
|
||||||
</li>
|
</li>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -191,7 +191,7 @@ header .discourse-tag {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete {
|
.autocomplete.ac-category-or-tag {
|
||||||
a {
|
a {
|
||||||
color: var(--primary-medium);
|
color: var(--primary-medium);
|
||||||
}
|
}
|
||||||
|
@ -203,6 +203,24 @@ header .discourse-tag {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hashtag-autocomplete {
|
||||||
|
.hashtag-autocomplete__option {
|
||||||
|
.hashtag-autocomplete__link {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--primary-medium);
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.d-icon {
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashtag-autocomplete__text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tags-admin-menu {
|
.tags-admin-menu {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
ul {
|
ul {
|
||||||
|
|
|
@ -3,46 +3,18 @@
|
||||||
class HashtagsController < ApplicationController
|
class HashtagsController < ApplicationController
|
||||||
requires_login
|
requires_login
|
||||||
|
|
||||||
HASHTAGS_PER_REQUEST = 20
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
raise Discourse::InvalidParameters.new(:slugs) if !params[:slugs].is_a?(Array)
|
raise Discourse::InvalidParameters.new(:slugs) if !params[:slugs].is_a?(Array)
|
||||||
|
render json: HashtagAutocompleteService.new(guardian).lookup(params[:slugs])
|
||||||
|
end
|
||||||
|
|
||||||
all_slugs = []
|
def search
|
||||||
tag_slugs = []
|
params.require(:term)
|
||||||
|
params.require(:order)
|
||||||
|
raise Discourse::InvalidParameters.new(:order) if !params[:order].is_a?(Array)
|
||||||
|
|
||||||
params[:slugs][0..HASHTAGS_PER_REQUEST].each do |slug|
|
results = HashtagAutocompleteService.new(guardian).search(params[:term], params[:order])
|
||||||
if slug.end_with?(PrettyText::Helpers::TAG_HASHTAG_POSTFIX)
|
|
||||||
tag_slugs << slug.chomp(PrettyText::Helpers::TAG_HASHTAG_POSTFIX)
|
|
||||||
else
|
|
||||||
all_slugs << slug
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Try to resolve hashtags as categories first
|
render json: success_json.merge(results: results)
|
||||||
category_slugs_and_ids = all_slugs.map { |slug| [slug, Category.query_from_hashtag_slug(slug)&.id] }.to_h
|
|
||||||
category_ids_and_urls = Category
|
|
||||||
.secured(guardian)
|
|
||||||
.select(:id, :slug, :parent_category_id) # fields required for generating category URL
|
|
||||||
.where(id: category_slugs_and_ids.values)
|
|
||||||
.map { |c| [c.id, c.url] }
|
|
||||||
.to_h
|
|
||||||
categories_hashtags = {}
|
|
||||||
category_slugs_and_ids.each do |slug, id|
|
|
||||||
if category_url = category_ids_and_urls[id]
|
|
||||||
categories_hashtags[slug] = category_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Resolve remaining hashtags as tags
|
|
||||||
tag_hashtags = {}
|
|
||||||
if SiteSetting.tagging_enabled
|
|
||||||
tag_slugs += (all_slugs - categories_hashtags.keys)
|
|
||||||
DiscourseTagging.filter_visible(Tag.where_name(tag_slugs), guardian).each do |tag|
|
|
||||||
tag_hashtags[tag.name] = tag.full_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: { categories: categories_hashtags, tags: tag_hashtags }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,192 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class HashtagAutocompleteService
|
||||||
|
HASHTAGS_PER_REQUEST = 20
|
||||||
|
|
||||||
|
attr_reader :guardian
|
||||||
|
cattr_reader :data_sources
|
||||||
|
|
||||||
|
def self.register_data_source(type, &block)
|
||||||
|
@@data_sources[type] = block
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.clear_data_sources
|
||||||
|
@@data_sources = {}
|
||||||
|
|
||||||
|
register_data_source("category") do |guardian, term, limit|
|
||||||
|
guardian_categories = Site.new(guardian).categories
|
||||||
|
|
||||||
|
guardian_categories
|
||||||
|
.select { |category| category[:name].downcase.include?(term) }
|
||||||
|
.take(limit)
|
||||||
|
.map do |category|
|
||||||
|
HashtagItem.new.tap do |item|
|
||||||
|
item.text = category[:name]
|
||||||
|
item.slug = category[:slug]
|
||||||
|
|
||||||
|
# Single-level category heirarchy should be enough to distinguish between
|
||||||
|
# categories here.
|
||||||
|
item.ref =
|
||||||
|
if category[:parent_category_id]
|
||||||
|
parent_category =
|
||||||
|
guardian_categories.find { |c| c[:id] === category[:parent_category_id] }
|
||||||
|
category[:slug] if !parent_category
|
||||||
|
|
||||||
|
parent_slug = parent_category[:slug]
|
||||||
|
"#{parent_slug}:#{category[:slug]}"
|
||||||
|
else
|
||||||
|
category[:slug]
|
||||||
|
end
|
||||||
|
item.icon = "folder"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
register_data_source("tag") do |guardian, term, limit|
|
||||||
|
if SiteSetting.tagging_enabled
|
||||||
|
tags_with_counts, _ =
|
||||||
|
DiscourseTagging.filter_allowed_tags(
|
||||||
|
guardian,
|
||||||
|
term: term,
|
||||||
|
with_context: true,
|
||||||
|
limit: limit,
|
||||||
|
for_input: true,
|
||||||
|
)
|
||||||
|
TagsController
|
||||||
|
.tag_counts_json(tags_with_counts)
|
||||||
|
.take(limit)
|
||||||
|
.map do |tag|
|
||||||
|
HashtagItem.new.tap do |item|
|
||||||
|
item.text = "#{tag[:name]} x #{tag[:count]}"
|
||||||
|
item.slug = tag[:name]
|
||||||
|
item.icon = "tag"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
clear_data_sources
|
||||||
|
|
||||||
|
class HashtagItem
|
||||||
|
# The text to display in the UI autocomplete menu for the item.
|
||||||
|
attr_accessor :text
|
||||||
|
|
||||||
|
# Canonical slug for the item. Different from the ref, which can
|
||||||
|
# have the type as a suffix to distinguish between conflicts.
|
||||||
|
attr_accessor :slug
|
||||||
|
|
||||||
|
# The icon to display in the UI autocomplete menu for the item.
|
||||||
|
attr_accessor :icon
|
||||||
|
|
||||||
|
# Distinguishes between different entities e.g. tag, category.
|
||||||
|
attr_accessor :type
|
||||||
|
|
||||||
|
# Inserted into the textbox when an autocomplete item is selected,
|
||||||
|
# and must be unique so it can be used for lookups via the #lookup
|
||||||
|
# method above.
|
||||||
|
attr_accessor :ref
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(guardian)
|
||||||
|
@guardian = guardian
|
||||||
|
end
|
||||||
|
|
||||||
|
def lookup(slugs)
|
||||||
|
raise Discourse::InvalidParameters.new(:slugs) if !slugs.is_a?(Array)
|
||||||
|
|
||||||
|
all_slugs = []
|
||||||
|
tag_slugs = []
|
||||||
|
|
||||||
|
slugs[0..HashtagAutocompleteService::HASHTAGS_PER_REQUEST].each do |slug|
|
||||||
|
if slug.end_with?(PrettyText::Helpers::TAG_HASHTAG_POSTFIX)
|
||||||
|
tag_slugs << slug.chomp(PrettyText::Helpers::TAG_HASHTAG_POSTFIX)
|
||||||
|
else
|
||||||
|
all_slugs << slug
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try to resolve hashtags as categories first
|
||||||
|
category_slugs_and_ids =
|
||||||
|
all_slugs.map { |slug| [slug, Category.query_from_hashtag_slug(slug)&.id] }.to_h
|
||||||
|
category_ids_and_urls =
|
||||||
|
Category
|
||||||
|
.secured(guardian)
|
||||||
|
.select(:id, :slug, :parent_category_id) # fields required for generating category URL
|
||||||
|
.where(id: category_slugs_and_ids.values)
|
||||||
|
.map { |c| [c.id, c.url] }
|
||||||
|
.to_h
|
||||||
|
categories_hashtags = {}
|
||||||
|
category_slugs_and_ids.each do |slug, id|
|
||||||
|
if category_url = category_ids_and_urls[id]
|
||||||
|
categories_hashtags[slug] = category_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Resolve remaining hashtags as tags
|
||||||
|
tag_hashtags = {}
|
||||||
|
if SiteSetting.tagging_enabled
|
||||||
|
tag_slugs += (all_slugs - categories_hashtags.keys)
|
||||||
|
DiscourseTagging
|
||||||
|
.filter_visible(Tag.where_name(tag_slugs), guardian)
|
||||||
|
.each { |tag| tag_hashtags[tag.name] = tag.full_url }
|
||||||
|
end
|
||||||
|
|
||||||
|
{ categories: categories_hashtags, tags: tag_hashtags }
|
||||||
|
end
|
||||||
|
|
||||||
|
def search(term, types_in_priority_order, limit = 5)
|
||||||
|
raise Discourse::InvalidParameters.new(:order) if !types_in_priority_order.is_a?(Array)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
slugs_by_type = {}
|
||||||
|
term = term.downcase
|
||||||
|
types_in_priority_order =
|
||||||
|
types_in_priority_order.select { |type| @@data_sources.keys.include?(type) }
|
||||||
|
|
||||||
|
types_in_priority_order.each do |type|
|
||||||
|
data = @@data_sources[type].call(guardian, term, limit - results.length)
|
||||||
|
next if data.empty?
|
||||||
|
|
||||||
|
all_data_items_valid = data.all? do |item|
|
||||||
|
item.kind_of?(HashtagItem) && item.slug.present? && item.text.present?
|
||||||
|
end
|
||||||
|
next if !all_data_items_valid
|
||||||
|
|
||||||
|
data.each do |item|
|
||||||
|
item.type = type
|
||||||
|
item.ref = item.ref || item.slug
|
||||||
|
end
|
||||||
|
slugs_by_type[type] = data.map(&:slug)
|
||||||
|
|
||||||
|
results.concat(data)
|
||||||
|
|
||||||
|
break if results.length >= limit
|
||||||
|
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 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
|
||||||
|
top_ranked_type = slugs_by_type.keys.first
|
||||||
|
results.each do |hashtag_item|
|
||||||
|
next if hashtag_item.type == top_ranked_type
|
||||||
|
|
||||||
|
other_slugs = results.reject { |r| r.type === hashtag_item.type }.map(&:slug)
|
||||||
|
if other_slugs.include?(hashtag_item.slug)
|
||||||
|
hashtag_item.ref = "#{hashtag_item.slug}::#{hashtag_item.type}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
results.take(limit)
|
||||||
|
end
|
||||||
|
end
|
|
@ -771,6 +771,7 @@ Discourse::Application.routes.draw do
|
||||||
end
|
end
|
||||||
|
|
||||||
get "hashtags" => "hashtags#show"
|
get "hashtags" => "hashtags#show"
|
||||||
|
get "hashtags/search" => "hashtags#search"
|
||||||
|
|
||||||
TopTopic.periods.each do |period|
|
TopTopic.periods.each do |period|
|
||||||
get "top/#{period}.rss", to: redirect("top.rss?period=#{period}", status: 301)
|
get "top/#{period}.rss", to: redirect("top.rss?period=#{period}", status: 301)
|
||||||
|
|
|
@ -1086,6 +1086,16 @@ class Plugin::Instance
|
||||||
About.add_plugin_stat_group(plugin_stat_group_name, show_in_ui: show_in_ui, &block)
|
About.add_plugin_stat_group(plugin_stat_group_name, show_in_ui: show_in_ui, &block)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Registers a new record type to be searched via the HashtagAutocompleteService and the
|
||||||
|
# /hashtags/search endpoint. The data returned by the block must be an array
|
||||||
|
# with each item an instance of HashtagAutocompleteService::HashtagItem.
|
||||||
|
#
|
||||||
|
# See also registerHashtagSearchParam in the plugin JS API, otherwise the
|
||||||
|
# clientside hashtag search code will use the new type registered here.
|
||||||
|
def register_hashtag_data_source(type, &block)
|
||||||
|
HashtagAutocompleteService.register_data_source(type, &block)
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def self.js_path
|
def self.js_path
|
||||||
|
|
|
@ -155,6 +155,9 @@ module TestSetup
|
||||||
# Make sure the default Post and Topic bookmarkables are registered
|
# Make sure the default Post and Topic bookmarkables are registered
|
||||||
Bookmark.reset_bookmarkables
|
Bookmark.reset_bookmarkables
|
||||||
|
|
||||||
|
# Make sure only the default category and tag hashtag data sources are registered.
|
||||||
|
HashtagAutocompleteService.clear_data_sources
|
||||||
|
|
||||||
OmniAuth.config.test_mode = false
|
OmniAuth.config.test_mode = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe HashtagAutocompleteService do
|
||||||
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
fab!(:category1) { Fabricate(:category, name: "Book Club", slug: "book-club") }
|
||||||
|
fab!(:tag1) { Fabricate(:tag, name: "great-books") }
|
||||||
|
let(:guardian) { Guardian.new(user) }
|
||||||
|
|
||||||
|
subject { described_class.new(guardian) }
|
||||||
|
|
||||||
|
before { Site.clear_cache }
|
||||||
|
|
||||||
|
def register_bookmark_data_source
|
||||||
|
HashtagAutocompleteService.register_data_source("bookmark") do |guardian_scoped, term, limit|
|
||||||
|
guardian_scoped
|
||||||
|
.user
|
||||||
|
.bookmarks
|
||||||
|
.where("name ILIKE ?", "%#{term}%")
|
||||||
|
.limit(limit)
|
||||||
|
.map do |bm|
|
||||||
|
HashtagAutocompleteService::HashtagItem.new.tap do |item|
|
||||||
|
item.text = bm.name
|
||||||
|
item.slug = bm.name.gsub(" ", "-")
|
||||||
|
item.icon = "bookmark"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#search" do
|
||||||
|
it "returns search results for tags and categories by default" do
|
||||||
|
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(
|
||||||
|
["Book Club", "great-books x 0"],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "respects the types_in_priority_order param" do
|
||||||
|
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(
|
||||||
|
["great-books x 0", "Book Club"],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "respects the limit param" do
|
||||||
|
expect(subject.search("book", %w[tag category], 1).map(&:text)).to eq(["great-books x 0"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "includes the tag count" do
|
||||||
|
tag1.update!(topic_count: 78)
|
||||||
|
expect(subject.search("book", %w[tag category], 1).map(&:text)).to eq(["great-books x 78"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does case-insensitive search" do
|
||||||
|
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(
|
||||||
|
["Book Club", "great-books x 0"],
|
||||||
|
)
|
||||||
|
expect(subject.search("bOOk", %w[category tag]).map(&:text)).to eq(
|
||||||
|
["Book Club", "great-books x 0"],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not include categories the user cannot access" do
|
||||||
|
category1.update!(read_restricted: true)
|
||||||
|
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(["great-books x 0"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not include tags the user cannot access" do
|
||||||
|
Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["great-books"])
|
||||||
|
expect(subject.search("book", %w[tag]).map(&:text)).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not search other data sources if the limit is reached by earlier type data sources" do
|
||||||
|
Site.any_instance.expects(:categories).never
|
||||||
|
subject.search("book", %w[tag category], 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "includes other data sources" do
|
||||||
|
Fabricate(:bookmark, user: user, name: "read review of this fantasy book")
|
||||||
|
Fabricate(:bookmark, user: user, name: "cool rock song")
|
||||||
|
guardian.user.reload
|
||||||
|
|
||||||
|
HashtagAutocompleteService.register_data_source("bookmark") do |guardian_scoped, term, limit|
|
||||||
|
guardian_scoped
|
||||||
|
.user
|
||||||
|
.bookmarks
|
||||||
|
.where("name ILIKE ?", "%#{term}%")
|
||||||
|
.limit(limit)
|
||||||
|
.map do |bm|
|
||||||
|
HashtagAutocompleteService::HashtagItem.new.tap do |item|
|
||||||
|
item.text = bm.name
|
||||||
|
item.slug = bm.name.dasherize
|
||||||
|
item.icon = "bookmark"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(subject.search("book", %w[category tag bookmark]).map(&:text)).to eq(
|
||||||
|
["Book Club", "great-books x 0", "read review of this fantasy book"],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "handles refs for categories that have a parent" do
|
||||||
|
parent = Fabricate(:category, name: "Hobbies", slug: "hobbies")
|
||||||
|
category1.update!(parent_category: parent)
|
||||||
|
expect(subject.search("book", %w[category tag]).map(&:ref)).to eq(
|
||||||
|
%w[hobbies:book-club great-books],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "appends type suffixes for the ref on conflicting slugs on items that are not the top priority type" do
|
||||||
|
Fabricate(:tag, name: "book-club")
|
||||||
|
expect(subject.search("book", %w[category tag]).map(&:ref)).to eq(
|
||||||
|
%w[book-club great-books book-club::tag],
|
||||||
|
)
|
||||||
|
|
||||||
|
Fabricate(:bookmark, user: user, name: "book club")
|
||||||
|
guardian.user.reload
|
||||||
|
|
||||||
|
register_bookmark_data_source
|
||||||
|
|
||||||
|
expect(subject.search("book", %w[category tag bookmark]).map(&:ref)).to eq(
|
||||||
|
%w[book-club great-books book-club::tag book-club::bookmark],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when not tagging_enabled" do
|
||||||
|
before { SiteSetting.tagging_enabled = false }
|
||||||
|
|
||||||
|
it "does not return any tags" do
|
||||||
|
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(["Book Club"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue