FEATURE: Autolinking to category using hashtags.

This commit is contained in:
Guo Xiang Tan 2015-12-28 14:28:16 +08:00
parent b8471177dc
commit c1dbf5c1c4
20 changed files with 365 additions and 14 deletions

View File

@ -1,18 +1,19 @@
import { categoryBadgeHTML } from 'discourse/helpers/category-link';
import Category from 'discourse/models/category';
export default Ember.Component.extend({
_initializeAutocomplete: function() {
const self = this,
template = this.container.lookup('template:category-group-autocomplete.raw'),
regexp = new RegExp("href=['\"]" + Discourse.getURL('/c/') + "([^'\"]+)");
regexp = new RegExp(`href=['\"]${Discourse.getURL('/c/')}([^'\"]+)`);
this.$('input').autocomplete({
items: this.get('categories'),
single: false,
allowAny: false,
dataSource(term){
return Discourse.Category.list().filter(function(category){
return Category.list().filter(function(category){
const regex = new RegExp(term, "i");
return category.get("name").match(regex) &&
!_.contains(self.get('blacklist') || [], category) &&
@ -22,7 +23,7 @@ export default Ember.Component.extend({
onChangeItems(items) {
const categories = _.map(items, function(link) {
const slug = link.match(regexp)[1];
return Discourse.Category.findSingleBySlug(slug);
return Category.findSingleBySlug(slug);
});
Em.run.next(() => self.set("categories", categories));
},

View File

@ -1,6 +1,7 @@
import userSearch from 'discourse/lib/user-search';
import { default as computed, on } from 'ember-addons/ember-computed-decorators';
import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions';
import { linkSeenCategoryHashtags, fetchUnseenCategoryHashtags } from 'discourse/lib/link-category-hashtags';
export default Ember.Component.extend({
classNames: ['wmd-controls'],
@ -111,13 +112,19 @@ export default Ember.Component.extend({
$preview.scrollTop(desired + 50);
},
_renderUnseen: function($preview, unseen) {
fetchUnseenMentions($preview, unseen, this.siteSettings).then(() => {
_renderUnseenMentions: function($preview, unseen) {
fetchUnseenMentions($preview, unseen).then(() => {
linkSeenMentions($preview, this.siteSettings);
this._warnMentionedGroups($preview);
});
},
_renderUnseenCategoryHashtags: function($preview, unseen) {
fetchUnseenCategoryHashtags(unseen).then(() => {
linkSeenCategoryHashtags($preview);
});
},
_warnMentionedGroups($preview) {
Ember.run.scheduleOnce('afterRender', () => {
this._warnedMentions = this._warnedMentions || [];
@ -386,11 +393,17 @@ export default Ember.Component.extend({
// Paint mentions
const unseen = linkSeenMentions($preview, this.siteSettings);
if (unseen.length) {
Ember.run.debounce(this, this._renderUnseen, $preview, unseen, 500);
Ember.run.debounce(this, this._renderUnseenMentions, $preview, unseen, 500);
}
this._warnMentionedGroups($preview);
// Paint category hashtags
const unseenHashtags = linkSeenCategoryHashtags($preview);
if (unseenHashtags.length) {
Ember.run.debounce(this, this._renderUnseenCategoryHashtags, $preview, unseenHashtags, 500);
}
const post = this.get('composer.post');
let refresh = false;

View File

@ -2,6 +2,7 @@
import loadScript from 'discourse/lib/load-script';
import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators';
import { showSelector } from "discourse/lib/emoji/emoji-toolbar";
import Category from 'discourse/models/category';
// Our head can be a static string or a function that returns a string
// based on input (like for numbered lists).
@ -175,7 +176,11 @@ export default Ember.Component.extend({
@on('didInsertElement')
_startUp() {
this._applyEmojiAutocomplete();
const container = this.get('container'),
$editorInput = this.$('.d-editor-input');
this._applyEmojiAutocomplete(container, $editorInput);
this._applyCategoryHashtagAutocomplete(container, $editorInput);
loadScript('defer/html-sanitizer-bundle').then(() => this.set('ready', true));
@ -243,14 +248,52 @@ export default Ember.Component.extend({
Ember.run.debounce(this, this._updatePreview, 30);
},
_applyEmojiAutocomplete() {
_applyCategoryHashtagAutocomplete(container, $editorInput) {
const template = container.lookup('template:category-group-autocomplete.raw');
$editorInput.autocomplete({
template: template,
key: '#',
transformComplete(category) {
return category.get('slug');
},
dataSource(term) {
return Category.list().filter(category => {
const regexp = new RegExp(term, 'i');
return category.get('name').match(regexp);
});
},
triggerRule(textarea, opts) {
const result = Discourse.Utilities.caretRowCol(textarea);
const row = result.rowNum;
var col = result.colNum;
var 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;
}
if (col < 6) {
// Don't trigger autocomplete when ATX-style headers are used
return (line.slice(0, col) !== "#".repeat(col));
} else {
return true;
}
}
});
},
_applyEmojiAutocomplete(container, $editorInput) {
if (!this.siteSettings.enable_emoji) { return; }
const container = this.container;
const template = container.lookup('template:emoji-selector-autocomplete.raw');
const self = this;
this.$('.d-editor-input').autocomplete({
$editorInput.autocomplete({
template: template,
key: ":",

View File

@ -0,0 +1,23 @@
/**
Supports Discourse's category hashtags (#category-slug) for automatically
generating a link to the category.
**/
Discourse.Dialect.inlineRegexp({
start: '#',
matcher: /^#([A-Za-z0-9][A-Za-z0-9\-]{0,40}[A-Za-z0-9])/,
spaceOrTagBoundary: true,
emitter: function(matches) {
var slug = matches[1],
hashtag = matches[0],
attributeClass = 'hashtag',
categoryHashtagLookup = this.dialect.options.categoryHashtagLookup,
result = categoryHashtagLookup && categoryHashtagLookup(slug);
if (result && result[0] === "category") {
return ['a', { class: attributeClass, href: result[1] }, hashtag];
} else {
return ['span', { class: attributeClass }, hashtag];
}
}
});

View File

@ -282,6 +282,14 @@ export default function(options) {
}, 50);
});
const checkTriggerRule = (opts) => {
if (options.triggerRule) {
return options.triggerRule(me[0], opts);
} else {
return true;
}
};
$(this).on('keypress.autocomplete', function(e) {
var caretPosition, term;
@ -289,7 +297,7 @@ export default function(options) {
if (options.key && e.which === options.key.charCodeAt(0)) {
caretPosition = Discourse.Utilities.caretPosition(me[0]);
var prevChar = me.val().charAt(caretPosition - 1);
if (!prevChar || allowedLettersRegex.test(prevChar)) {
if (checkTriggerRule() && (!prevChar || allowedLettersRegex.test(prevChar))) {
completeStart = completeEnd = caretPosition;
updateAutoComplete(options.dataSource(""));
}
@ -343,7 +351,7 @@ export default function(options) {
stopFound = prev === options.key;
if (stopFound) {
prev = me[0].value[c - 1];
if (!prev || allowedLettersRegex.test(prev)) {
if (checkTriggerRule({ backSpace: true }) && (!prev || allowedLettersRegex.test(prev))) {
completeStart = c;
caretPosition = completeEnd = initial;
term = me[0].value.substring(c + 1, initial);

View File

@ -0,0 +1,53 @@
const validCategoryHashtags = {};
const checkedCategoryHashtags = [];
const testedKey = 'tested';
const testedClass = `hashtag-${testedKey}`;
function replaceSpan($elem, categorySlug, categoryLink) {
$elem.replaceWith(`<a href="${categoryLink}" class="hashtag">#${categorySlug}</a>`);
}
function updateFound($hashtags, categorySlugs) {
Ember.run.schedule('afterRender', () => {
$hashtags.each((index, hashtag) => {
const categorySlug = categorySlugs[index];
const link = validCategoryHashtags[categorySlug];
const $hashtag = $(hashtag);
if (link) {
replaceSpan($hashtag, categorySlug, link);
} else if (checkedCategoryHashtags.indexOf(categorySlug) !== -1) {
$hashtag.addClass(testedClass);
}
});
});
};
export function linkSeenCategoryHashtags($elem) {
const $hashtags = $(`span.hashtag:not(.${testedClass})`, $elem);
const unseen = [];
if ($hashtags.length) {
const categorySlugs = $hashtags.map((_, hashtag) => $(hashtag).text().substr(1));
if (categorySlugs.length) {
_.uniq(categorySlugs).forEach((categorySlug) => {
if (checkedCategoryHashtags.indexOf(categorySlug) === -1) {
unseen.push(categorySlug);
}
});
}
updateFound($hashtags, categorySlugs);
}
return unseen;
};
export function fetchUnseenCategoryHashtags(categorySlugs) {
return Discourse.ajax("/category_hashtags/check", { data: { category_slugs: categorySlugs } })
.then((response) => {
response.valid.forEach((category) => {
validCategoryHashtags[category.slug] = category.url;
});
checkedCategoryHashtags.push.apply(checkedCategoryHashtags, categorySlugs);
});
}

View File

@ -239,6 +239,7 @@ Discourse.Markdown.whiteListTag('a', 'class', 'attachment');
Discourse.Markdown.whiteListTag('a', 'class', 'onebox');
Discourse.Markdown.whiteListTag('a', 'class', 'mention');
Discourse.Markdown.whiteListTag('a', 'class', 'mention-group');
Discourse.Markdown.whiteListTag('a', 'class', 'hashtag');
Discourse.Markdown.whiteListTag('a', 'target', '_blank');
Discourse.Markdown.whiteListTag('a', 'rel', 'nofollow');
@ -251,6 +252,7 @@ Discourse.Markdown.whiteListTag('div', 'class', 'title');
Discourse.Markdown.whiteListTag('div', 'class', 'quote-controls');
Discourse.Markdown.whiteListTag('span', 'class', 'mention');
Discourse.Markdown.whiteListTag('span', 'class', 'hashtag');
Discourse.Markdown.whiteListTag('aside', 'class', 'quote');
Discourse.Markdown.whiteListTag('aside', 'data-*');

View File

@ -143,6 +143,19 @@ Discourse.Utilities = {
return String(text).trim();
},
// Determine the row and col of the caret in an element
caretRowCol: function(el) {
var caretPosition = Discourse.Utilities.caretPosition(el);
var rows = el.value.slice(0, caretPosition).split("\n");
var rowNum = rows.length;
var colNum = caretPosition - rows.splice(0, rowNum - 1).reduce(function(sum, row) {
return sum + row.length + 1;
}, 0);
return { rowNum: rowNum, colNum: colNum};
},
// Determine the position of the caret in an element
caretPosition: function(el) {
var r, rc, re;

View File

@ -42,6 +42,7 @@ export default function() {
this.route('parentCategory', { path: '/c/:slug' });
this.route('categoryNone', { path: '/c/:slug/none' });
this.route('category', { path: '/c/:parentSlug/:slug' });
this.route('categoryWithID', { path: '/c/:parentSlug/:slug/:id' });
// homepage
this.route(Discourse.Utilities.defaultHomepage(), { path: '/' });

View File

@ -0,0 +1,11 @@
import Category from 'discourse/models/category';
export default Discourse.Route.extend({
model: function(params) {
return Category.findById(params.id);
},
redirect: function(model) {
this.transitionTo(`/c/${Category.slugFor(model)}`);
}
});

View File

@ -0,0 +1,14 @@
class CategoryHashtagsController < ApplicationController
before_filter :ensure_logged_in
def check
category_slugs = params[:category_slugs]
category_slugs.each(&:downcase!)
valid_categories = Category.secured(guardian).where(slug: category_slugs).map do |category|
{ slug: category.slug, url: category.url_with_id }
end.compact
render json: { valid: valid_categories }
end
end

View File

@ -224,14 +224,22 @@ class ListController < ApplicationController
def set_category
slug_or_id = params.fetch(:category)
parent_slug_or_id = params[:parent_category]
id = params[:id].to_i
parent_category_id = nil
if parent_slug_or_id.present?
parent_category_id = Category.query_parent_category(parent_slug_or_id)
redirect_or_not_found and return if parent_category_id.blank?
redirect_or_not_found and return if parent_category_id.blank? && !id
end
@category = Category.query_category(slug_or_id, parent_category_id)
# Redirect if we have `/c/:parent_category/:category/:id`
if id
category = Category.find_by_id(id)
(redirect_to category.url, status: 301) && return if category
end
redirect_or_not_found and return if !@category
@description_meta = @category.description_text

View File

@ -416,6 +416,10 @@ SQL
url
end
def url_with_id
self.parent_category ? "#{url}/#{self.id}" : "#{Discourse.base_uri}/c/#{self.id}-#{self.slug}"
end
# If the name changes, try and update the category definition topic too if it's
# an exact match
def rename_category_definition

View File

@ -425,11 +425,12 @@ Discourse::Application.routes.draw do
get "c/:parent_category/:category.rss" => "list#category_feed", format: :rss
get "c/:category" => "list#category_latest"
get "c/:category/none" => "list#category_none_latest"
get "c/:parent_category/:category" => "list#parent_category_category_latest"
get "c/:parent_category/:category/(:id)" => "list#parent_category_category_latest", constraints: { id: /\d+/ }
get "c/:category/l/top" => "list#category_top", as: "category_top"
get "c/:category/none/l/top" => "list#category_none_top", as: "category_none_top"
get "c/:parent_category/:category/l/top" => "list#parent_category_category_top", as: "parent_category_category_top"
get "category_hashtags/check" => "category_hashtags#check"
TopTopic.periods.each do |period|
get "top/#{period}" => "list#top_#{period}"

View File

@ -48,6 +48,15 @@ module PrettyText
end
end
def category_hashtag_lookup(category_slug)
if category_slug
category = Category.find_by_slug(category_slug)
return ['category', category.url_with_id] if category
else
nil
end
end
def get_topic_info(topic_id)
return unless Fixnum === topic_id
# TODO this only handles public topics, secured one do not get this
@ -207,6 +216,7 @@ module PrettyText
context.eval("Discourse.Emoji.applyCustomEmojis();")
context.eval('opts["mentionLookup"] = function(u){return helpers.mention_lookup(u);}')
context.eval('opts["categoryHashtagLookup"] = function(c){return helpers.category_hashtag_lookup(c);}')
context.eval('opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({size: "tiny", avatarTemplate: helpers.avatar_template(p)});}')
context.eval('opts["getTopicInfo"] = function(i){return helpers.get_topic_info(i)};')
baked = context.eval('Discourse.Markdown.markdownConverter(opts).makeHtml(raw)')

View File

@ -0,0 +1,46 @@
require 'rails_helper'
describe CategoryHashtagsController do
describe "check" do
describe "logged in" do
before do
log_in(:user)
end
it 'only returns the categories that are valid' do
category = Fabricate(:category)
xhr :get, :check, category_slugs: [category.slug, 'none']
expect(JSON.parse(response.body)).to eq(
{ "valid" => [{ "slug" => category.slug, "url" => category.url_with_id }] }
)
end
it 'does not return restricted categories for a normal user' do
group = Fabricate(:group)
private_category = Fabricate(:private_category, group: group)
xhr :get, :check, category_slugs: [private_category.slug]
expect(JSON.parse(response.body)).to eq({ "valid" => [] })
end
it 'returns restricted categories for an admin' do
admin = log_in(:admin)
group = Fabricate(:group)
group.add(admin)
private_category = Fabricate(:private_category, group: group)
xhr :get, :check, category_slugs: [private_category.slug]
expect(JSON.parse(response.body)).to eq(
{ "valid" => [{ "slug" => private_category.slug, "url" => private_category.url_with_id }] }
)
end
end
describe "not logged in" do
it 'raises an exception' do
expect { xhr :get, :check, category_slugs: [] }.to raise_error(Discourse::NotLoggedIn)
end
end
end
end

View File

@ -83,6 +83,26 @@ describe ListController do
it { is_expected.to respond_with(:success) }
end
context 'with a link that has a parent slug, slug and id in its path' do
let(:child_category) { Fabricate(:category, parent_category: category) }
context "with valid slug" do
before do
xhr :get, :category_latest, parent_category: category.slug, category: child_category.slug, id: child_category.id
end
it { is_expected.to redirect_to(child_category.url) }
end
context "with invalid slug" do
before do
xhr :get, :category_latest, parent_category: 'random slug', category: 'random slug', id: child_category.id
end
it { is_expected.to redirect_to(child_category.url) }
end
end
context 'another category exists with a number at the beginning of its name' do
# One category has another category's id at the beginning of its name
let!(:other_category) { Fabricate(:category, name: "#{category.id} name") }

View File

@ -503,6 +503,22 @@ describe Category do
end
end
describe "#url_with_id" do
let(:category) { Fabricate(:category, name: 'cats') }
it "includes the id in the URL" do
expect(category.url_with_id).to eq("/c/#{category.id}-cats")
end
context "child category" do
let(:child_category) { Fabricate(:category, parent_category_id: category.id, name: 'dogs') }
it "includes the id in the URL" do
expect(child_category.url_with_id).to eq("/c/cats/dogs/#{child_category.id}")
end
end
end
describe "uncategorized" do
let(:cat) { Category.where(id: SiteSetting.uncategorized_category_id).first }

View File

@ -289,6 +289,46 @@ test("Mentions", function() {
"it allows mentions within HTML tags");
});
test("Category hashtags", () => {
var alwaysTrue = { categoryHashtagLookup: (function() { return ["category", "http://test.discourse.org/category-hashtag"]; }) };
cookedOptions("Check out #category-hashtag", alwaysTrue,
"<p>Check out <a class=\"hashtag\" href=\"http://test.discourse.org/category-hashtag\">#category-hashtag</a></p>",
"it translates category hashtag into links");
cooked("Check out #category-hashtag",
"<p>Check out <span class=\"hashtag\">#category-hashtag</span></p>",
"it does not translate category hashtag into links if it is not a valid category hashtag");
cookedOptions("[#category-hashtag](http://www.test.com)", alwaysTrue,
"<p><a href=\"http://www.test.com\">#category-hashtag</a></p>",
"it does not translate category hashtag within links");
cooked("```\n# #category-hashtag\n```",
"<p><pre><code class=\"lang-auto\"># #category-hashtag</code></pre></p>",
"it does not translate category hashtags to links in code blocks");
cooked("># #category-hashtag\n",
"<blockquote><h1><span class=\"hashtag\">#category-hashtag</span></h1></blockquote>",
"it handles category hashtags in simple quotes");
cooked("# #category-hashtag",
"<h1><span class=\"hashtag\">#category-hashtag</span></h1>",
"it works within ATX-style headers");
cooked("don't `#category-hashtag`",
"<p>don't <code>#category-hashtag</code></p>",
"it does not mention in an inline code block");
cooked("test #hashtag1/#hashtag2",
"<p>test <span class=\"hashtag\">#hashtag1</span>/#hashtag2</p>",
"it does not convert category hashtag not bounded by spaces");
cooked("<small>#category-hashtag</small>",
"<p><small><span class=\"hashtag\">#category-hashtag</span></small></p>",
"it works between HTML tags");
});
test("Heading", function() {
cooked("**Bold**\n----------", "<h2><strong>Bold</strong></h2>", "It will bold the heading");

View File

@ -158,3 +158,27 @@ test("defaultHomepage", function() {
Discourse.SiteSettings.top_menu = "latest|top|hot";
equal(utils.defaultHomepage(), "latest", "default homepage is the first item in the top_menu site setting");
});
test("caretRowCol", () => {
var textarea = document.createElement('textarea');
const content = document.createTextNode("01234\n56789\n012345");
textarea.appendChild(content);
textarea.setAttribute('id', 'test');
document.body.appendChild(textarea);
const assertResult = (setCaretPos, expectedRowNum, expectedColNum) => {
Discourse.Utilities.setCaretPosition(textarea, setCaretPos);
const result = Discourse.Utilities.caretRowCol(textarea);
equal(result.rowNum, expectedRowNum, "returns the right row of the caret");
equal(result.colNum, expectedColNum, "returns the right col of the caret");
};
assertResult(0, 1, 0);
assertResult(5, 1, 5);
assertResult(6, 2, 0);
assertResult(11, 2, 5);
assertResult(14, 3, 2);
document.body.removeChild(textarea);
});