diff --git a/app/assets/javascripts/discourse/components/category-group.js.es6 b/app/assets/javascripts/discourse/components/category-group.js.es6 index 887e4ad7b20..4daca78a652 100644 --- a/app/assets/javascripts/discourse/components/category-group.js.es6 +++ b/app/assets/javascripts/discourse/components/category-group.js.es6 @@ -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)); }, diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 51a7a8fe143..9dbd7f25d14 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -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; diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index ceaad81bb52..a67b4b8cc3f 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -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: ":", diff --git a/app/assets/javascripts/discourse/dialects/category_hashtag_dialect.js b/app/assets/javascripts/discourse/dialects/category_hashtag_dialect.js new file mode 100644 index 00000000000..e428b7ed5f3 --- /dev/null +++ b/app/assets/javascripts/discourse/dialects/category_hashtag_dialect.js @@ -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]; + } + } +}); diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 index 78690f624b5..f1b24853ab1 100644 --- a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 +++ b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 @@ -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); diff --git a/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6 b/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6 new file mode 100644 index 00000000000..446e1aa53a2 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/link-category-hashtags.js.es6 @@ -0,0 +1,53 @@ +const validCategoryHashtags = {}; +const checkedCategoryHashtags = []; +const testedKey = 'tested'; +const testedClass = `hashtag-${testedKey}`; + +function replaceSpan($elem, categorySlug, categoryLink) { + $elem.replaceWith(`#${categorySlug}`); +} + +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); + }); +} diff --git a/app/assets/javascripts/discourse/lib/markdown.js b/app/assets/javascripts/discourse/lib/markdown.js index 4b9e3ce1e8f..29616a7e27b 100644 --- a/app/assets/javascripts/discourse/lib/markdown.js +++ b/app/assets/javascripts/discourse/lib/markdown.js @@ -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-*'); diff --git a/app/assets/javascripts/discourse/lib/utilities.js b/app/assets/javascripts/discourse/lib/utilities.js index 72cd08816b5..d3da0bcb022 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js +++ b/app/assets/javascripts/discourse/lib/utilities.js @@ -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; diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index 6aa420a88c4..f24ee0ef3dc 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -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: '/' }); diff --git a/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6 b/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6 new file mode 100644 index 00000000000..c36b4bae792 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6 @@ -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)}`); + } +}); diff --git a/app/controllers/category_hashtags_controller.rb b/app/controllers/category_hashtags_controller.rb new file mode 100644 index 00000000000..6e78a92ec73 --- /dev/null +++ b/app/controllers/category_hashtags_controller.rb @@ -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 diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index 07fec8d6a3b..cd59e309877 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -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 diff --git a/app/models/category.rb b/app/models/category.rb index bd5e85914fb..80b2a1a5c7d 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 638396dd327..2859b5bffc7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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}" diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index aefd4bed006..126f3358c2e 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -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)') diff --git a/spec/controllers/category_hashtags_controller_spec.rb b/spec/controllers/category_hashtags_controller_spec.rb new file mode 100644 index 00000000000..01d5eeb74f1 --- /dev/null +++ b/spec/controllers/category_hashtags_controller_spec.rb @@ -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 diff --git a/spec/controllers/list_controller_spec.rb b/spec/controllers/list_controller_spec.rb index 0415074b903..59c20da024f 100644 --- a/spec/controllers/list_controller_spec.rb +++ b/spec/controllers/list_controller_spec.rb @@ -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") } diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index f466be73c65..ff253badbb8 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -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 } diff --git a/test/javascripts/lib/markdown-test.js.es6 b/test/javascripts/lib/markdown-test.js.es6 index 818b7551411..fde64778ba6 100644 --- a/test/javascripts/lib/markdown-test.js.es6 +++ b/test/javascripts/lib/markdown-test.js.es6 @@ -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, + "
Check out #category-hashtag
", + "it translates category hashtag into links"); + + cooked("Check out #category-hashtag", + "Check out #category-hashtag
", + "it does not translate category hashtag into links if it is not a valid category hashtag"); + + cookedOptions("[#category-hashtag](http://www.test.com)", alwaysTrue, + "", + "it does not translate category hashtag within links"); + + cooked("```\n# #category-hashtag\n```", + "# #category-hashtag
",
+ "it does not translate category hashtags to links in code blocks");
+
+ cooked("># #category-hashtag\n",
+ "", + "it handles category hashtags in simple quotes"); + + cooked("# #category-hashtag", + "#category-hashtag
don't #category-hashtag
test #hashtag1/#hashtag2
", + "it does not convert category hashtag not bounded by spaces"); + + cooked("#category-hashtag", + "#category-hashtag
", + "it works between HTML tags"); +}); + test("Heading", function() { cooked("**Bold**\n----------", "