FEATURE: Merge tagging plugin into core

This commit is contained in:
Neil Lalonde 2016-04-25 15:55:15 -04:00
parent 7151c16c79
commit e5918c7d00
93 changed files with 2484 additions and 20 deletions

2
.gitignore vendored
View file

@ -4,8 +4,6 @@
# or operating system, you probably want to add a global ignore instead:
# git config --global core.excludesfile ~/.gitignore_global
tags
.DS_Store
._.DS_Store
dump.rdb

View file

@ -11,6 +11,7 @@ var _pluginCallbacks = [];
window.Discourse = Ember.Application.createWithMixins(Discourse.Ajax, {
rootElement: '#main',
_docTitle: document.title,
__TAGS_INCLUDED__: true,
getURL: function(url) {
if (!url) return url;

View file

@ -0,0 +1,7 @@
import RESTAdapter from 'discourse/adapters/rest';
export default RESTAdapter.extend({
pathFor(store, type, id) {
return "/tags/" + id + "/notifications";
}
});

View file

@ -2,6 +2,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';
import { fetchUnseenTagHashtags, linkSeenTagHashtags } from 'discourse/lib/link-tag-hashtag';
export default Ember.Component.extend({
classNames: ['wmd-controls'],
@ -27,6 +28,22 @@ export default Ember.Component.extend({
return showPreview ? I18n.t('composer.hide_preview') : I18n.t('composer.show_preview');
},
_renderUnseenTagHashtags($preview, unseen) {
fetchUnseenTagHashtags(unseen).then(() => {
linkSeenTagHashtags($preview);
});
},
@on('previewRefreshed')
paintTagHashtags($preview) {
if (!this.siteSettings.tagging_enabled) { return; }
const unseenTagHashtags = linkSeenTagHashtags($preview);
if (unseenTagHashtags.length) {
Ember.run.debounce(this, this._renderUnseenTagHashtags, $preview, unseenTagHashtags, 500);
}
},
@computed
markdownOptions() {
return {

View file

@ -3,9 +3,10 @@ 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';
import { SEPARATOR as categoryHashtagSeparator,
categoryHashtagTriggerRule
} from 'discourse/lib/category-hashtags';
import { categoryHashtagTriggerRule } from 'discourse/lib/category-hashtags';
import { TAG_HASHTAG_POSTFIX } from 'discourse/lib/tag-hashtags';
import { search as searchCategoryTag } from 'discourse/lib/category-tag-search';
import { SEPARATOR } from 'discourse/lib/category-hashtags';
// Our head can be a static string or a function that returns a string
// based on input (like for numbered lists).
@ -278,17 +279,22 @@ export default Ember.Component.extend({
Ember.run.debounce(this, this._updatePreview, 30);
},
_applyCategoryHashtagAutocomplete(container, $editorInput) {
const template = container.lookup('template:category-group-autocomplete.raw');
_applyCategoryHashtagAutocomplete(container) {
const template = container.lookup('template:category-tag-autocomplete.raw');
const siteSettings = this.siteSettings;
$editorInput.autocomplete({
this.$('.d-editor-input').autocomplete({
template: template,
key: '#',
transformComplete(category) {
return Category.slugFor(category, categoryHashtagSeparator);
transformComplete(obj) {
if (obj.model) {
return Category.slugFor(obj.model, SEPARATOR);
} else {
return `${obj.text}${TAG_HASHTAG_POSTFIX}`;
}
},
dataSource(term) {
return Category.search(term);
return searchCategoryTag(term, siteSettings);
},
triggerRule(textarea, opts) {
return categoryHashtagTriggerRule(textarea, opts);

View file

@ -0,0 +1,13 @@
export default Ember.Component.extend({
tagName: 'a',
classNameBindings: [':discourse-tag', 'style', 'tagClass'],
attributeBindings: ['href'],
tagClass: function() {
return "tag-" + this.get('tagRecord.id');
}.property('tagRecord.id'),
href: function() {
return '/tags/' + this.get('tagRecord.id');
}.property('tagRecord.id'),
});

View file

@ -0,0 +1,97 @@
import renderTag from 'discourse/lib/render-tag';
function formatTag(t) {
return renderTag(t.id, {count: t.count});
}
export default Ember.TextField.extend({
classNameBindings: [':tag-chooser'],
attributeBindings: ['tabIndex'],
_setupTags: function() {
const tags = this.get('tags') || [];
this.set('value', tags.join(", "));
}.on('init'),
_valueChanged: function() {
const tags = this.get('value').split(',').map(v => v.trim()).reject(v => v.length === 0).uniq();
this.set('tags', tags);
}.observes('value'),
_initializeTags: function() {
const site = this.site,
self = this,
filterRegexp = new RegExp(this.site.tags_filter_regexp, "g");
this.$().select2({
tags: true,
placeholder: I18n.t('tagging.choose_for_topic'),
maximumInputLength: this.siteSettings.max_tag_length,
maximumSelectionSize: this.siteSettings.max_tags_per_topic,
initSelection(element, callback) {
const data = [];
function splitVal(string, separator) {
var val, i, l;
if (string === null || string.length < 1) return [];
val = string.split(separator);
for (i = 0, l = val.length; i < l; i = i + 1) val[i] = $.trim(val[i]);
return val;
}
$(splitVal(element.val(), ",")).each(function () {
data.push({
id: this,
text: this
});
});
callback(data);
},
createSearchChoice: function(term, data) {
term = term.replace(filterRegexp, '').trim();
// No empty terms, make sure the user has permission to create the tag
if (!term.length || !site.get('can_create_tag')) { return; }
if ($(data).filter(function() {
return this.text.localeCompare(term) === 0;
}).length === 0) {
return { id: term, text: term };
}
},
createSearchChoicePosition: function(list, item) {
// Search term goes on the bottom
list.push(item);
},
formatSelection: function (data) {
return data ? renderTag(this.text(data)) : undefined;
},
formatSelectionCssClass: function(){
return "discourse-tag-select2";
},
formatResult: formatTag,
multiple: true,
ajax: {
quietMillis: 200,
cache: true,
url: Discourse.getURL("/tags/filter/search"),
dataType: 'json',
data: function (term) {
return { q: term, limit: self.siteSettings.max_tag_search_results };
},
results: function (data) {
if (self.siteSettings.tags_sort_alphabetically) {
data.results = data.results.sort(function(a,b) { return a.id > b.id; });
}
return data;
}
},
});
}.on('didInsertElement'),
_destroyTags: function() {
this.$().select2('destroy');
}.on('willDestroyElement')
});

View file

@ -0,0 +1,29 @@
import DiscourseURL from 'discourse/lib/url';
export default Ember.Component.extend({
tagName: 'a',
classNameBindings: [':tag-badge-wrapper', ':badge-wrapper', ':bullet', 'tagClass'],
attributeBindings: ['href'],
href: function() {
var url = '/tags';
if (this.get('category')) {
url += this.get('category.url');
}
return url + '/' + this.get('tagId');
}.property('tagId', 'category'),
tagClass: function() {
return "tag-" + this.get('tagId');
}.property('tagId'),
render(buffer) {
buffer.push(Handlebars.Utils.escapeExpression(this.get('tagId')));
},
click(e) {
e.preventDefault();
DiscourseURL.routeTo(this.get('href'));
return true;
}
});

View file

@ -0,0 +1,113 @@
import { setting } from 'discourse/lib/computed';
export default Ember.Component.extend({
classNameBindings: [':tag-drop', 'tag::no-category', 'tags:has-drop','categoryStyle','tagClass'],
categoryStyle: setting('category_style'), // match the category-drop style
currentCategory: Em.computed.or('secondCategory', 'firstCategory'),
showFilterByTag: setting('show_filter_by_tag'),
showTagDropdown: Em.computed.and('showFilterByTag', 'tags'),
tagId: null,
tagName: 'li',
tags: function() {
if (this.siteSettings.tags_sort_alphabetically && Discourse.Site.currentProp('top_tags')) {
return Discourse.Site.currentProp('top_tags').sort();
} else {
return Discourse.Site.currentProp('top_tags');
}
}.property('site.top_tags'),
iconClass: function() {
if (this.get('expanded')) { return "fa fa-caret-down"; }
return "fa fa-caret-right";
}.property('expanded'),
tagClass: function() {
if (this.get('tagId')) {
return "tag-" + this.get('tagId');
} else {
return "tag_all";
}
}.property('tagId'),
allTagsUrl: function() {
if (this.get('currentCategory')) {
return this.get('currentCategory.url') + "?allTags=1";
} else {
return "/";
}
}.property('firstCategory', 'secondCategory'),
allTagsLabel: function() {
return I18n.t("tagging.selector_all_tags");
}.property('tag'),
dropdownButtonClass: function() {
var result = 'badge-category category-dropdown-button';
if (Em.isNone(this.get('tag'))) {
result += ' home';
}
return result;
}.property('tag'),
clickEventName: function() {
return "click.tag-drop-" + (this.get('tag') || "all");
}.property('tag'),
actions: {
expand: function() {
var self = this;
if(!this.get('renderTags')){
this.set('renderTags',true);
Em.run.next(function(){
self.send('expand');
});
return;
}
if (this.get('expanded')) {
this.close();
return;
}
if (this.get('tags')) {
this.set('expanded', true);
}
var $dropdown = this.$()[0];
this.$('a[data-drop-close]').on('click.tag-drop', function() {
self.close();
});
Em.run.next(function(){
self.$('.cat a').add('html').on(self.get('clickEventName'), function(e) {
var $target = $(e.target),
closest = $target.closest($dropdown);
if ($(e.currentTarget).hasClass('badge-wrapper')){
self.close();
}
return ($(e.currentTarget).hasClass('badge-category') || (closest.length && closest[0] === $dropdown)) ? true : self.close();
});
});
}
},
removeEvents: function(){
$('html').off(this.get('clickEventName'));
this.$('a[data-drop-close]').off('click.tag-drop');
},
close: function() {
this.removeEvents();
this.set('expanded', false);
},
willDestroyElement: function() {
this.removeEvents();
}
});

View file

@ -0,0 +1,11 @@
import NotificationsButton from 'discourse/components/notifications-button';
export default NotificationsButton.extend({
classNames: ['notification-options', 'tag-notification-menu'],
buttonIncludesText: false,
i18nPrefix: 'tagging.notifications',
clicked(id) {
this.sendAction('action', id);
}
});

View file

@ -81,6 +81,14 @@ export default Ember.Controller.extend({
this.set('similarTopics', []);
}.on('init'),
@computed('model.canEditTitle', 'model.creatingPrivateMessage')
canEditTags(canEditTitle, creatingPrivateMessage) {
return !this.site.mobileView &&
this.site.get('can_tag_topics') &&
canEditTitle &&
!creatingPrivateMessage;
},
@computed('model.action')
canWhisper(action) {
const currentUser = this.currentUser;

View file

@ -3,6 +3,15 @@ import { categoryBadgeHTML } from 'discourse/helpers/category-link';
import computed from 'ember-addons/ember-computed-decorators';
import { propertyGreaterThan, propertyLessThan } from 'discourse/lib/computed';
function customTagArray(fieldName) {
return function() {
var val = this.get(fieldName);
if (!val) { return val; }
if (!Array.isArray(val)) { val = [val]; }
return val;
}.property(fieldName);
}
// This controller handles displaying of history
export default Ember.Controller.extend(ModalFunctionality, {
loading: true,
@ -13,6 +22,9 @@ export default Ember.Controller.extend(ModalFunctionality, {
if (this.site.mobileView) { this.set("viewMode", "inline"); }
}.on("init"),
previousTagChanges: customTagArray('model.tags_changes.previous'),
currentTagChanges: customTagArray('model.tags_changes.current'),
refresh(postId, postVersion) {
this.set("loading", true);

View file

@ -0,0 +1,25 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import BufferedContent from 'discourse/mixins/buffered-content';
export default Ember.Controller.extend(ModalFunctionality, BufferedContent, {
renameDisabled: function() {
const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g"),
newId = this.get('buffered.id').replace(filterRegexp, '').trim();
return (newId.length === 0) || (newId === this.get('model.id'));
}.property('buffered.id', 'id'),
actions: {
performRename() {
const tag = this.get('model'),
self = this;
tag.update({ id: this.get('buffered.id') }).then(function() {
self.send('closeModal');
self.transitionToRoute('tags.show', tag.get('id'));
}).catch(function() {
self.flash(I18n.t('generic_error'), 'error');
});
}
}
});

View file

@ -0,0 +1,15 @@
export default Ember.Controller.extend({
sortProperties: ['count:desc', 'id'],
sortedTags: Ember.computed.sort('model', 'sortProperties'),
actions: {
sortByCount() {
this.set('sortProperties', ['count:desc', 'id']);
},
sortById() {
this.set('sortProperties', ['id']);
}
}
});

View file

@ -0,0 +1,133 @@
import BulkTopicSelection from "discourse/mixins/bulk-topic-selection";
import { default as NavItem, extraNavItemProperties, customNavItemHref } from 'discourse/models/nav-item';
if (extraNavItemProperties) {
extraNavItemProperties(function(text, opts) {
if (opts && opts.tagId) {
return {tagId: opts.tagId};
} else {
return {};
}
});
}
if (customNavItemHref) {
customNavItemHref(function(navItem) {
if (navItem.get('tagId')) {
var name = navItem.get('name');
if ( !Discourse.Site.currentProp('filters').contains(name) ) {
return null;
}
var path = "/tags/",
category = navItem.get("category");
if(category){
path += "c/";
path += Discourse.Category.slugFor(category);
if (navItem.get('noSubcategories')) { path += '/none'; }
path += "/";
}
path += navItem.get('tagId') + "/l/";
return path + name.replace(' ', '-');
} else {
return null;
}
});
}
export default Ember.Controller.extend(BulkTopicSelection, {
needs: ["application"],
tag: null,
list: null,
canAdminTag: Ember.computed.alias("currentUser.staff"),
filterMode: null,
navMode: 'latest',
loading: false,
canCreateTopic: false,
order: 'default',
ascending: false,
status: null,
state: null,
search: null,
max_posts: null,
q: null,
queryParams: ['order', 'ascending', 'status', 'state', 'search', 'max_posts', 'q'],
navItems: function() {
return NavItem.buildList(this.get('category'), {tagId: this.get('tag.id'), filterMode: this.get('filterMode')});
}.property('category', 'tag.id', 'filterMode'),
showTagFilter: function() {
return Discourse.SiteSettings.show_filter_by_tag;
}.property('category'),
categories: function() {
return Discourse.Category.list();
}.property(),
showAdminControls: function() {
return this.get('canAdminTag') && !this.get('category');
}.property('canAdminTag', 'category'),
loadMoreTopics() {
return this.get("list").loadMore();
},
_showFooter: function() {
this.set("controllers.application.showFooter", !this.get("list.canLoadMore"));
}.observes("list.canLoadMore"),
footerMessage: function() {
if (this.get('loading') || this.get('list.topics.length') !== 0) { return; }
if (this.get('list.topics.length') === 0) {
return I18n.t('tagging.topics.none.' + this.get('navMode'), {tag: this.get('tag.id')});
} else {
return I18n.t('tagging.topics.bottom.' + this.get('navMode'), {tag: this.get('tag.id')});
}
}.property('navMode', 'list.topics.length', 'loading'),
actions: {
changeSort(sortBy) {
if (sortBy === this.get('order')) {
this.toggleProperty('ascending');
} else {
this.setProperties({ order: sortBy, ascending: false });
}
this.send('invalidateModel');
},
refresh() {
const self = this;
// TODO: this probably doesn't work anymore
return this.store.findFiltered('topicList', {filter: 'tags/' + this.get('tag.id')}).then(function(list) {
self.set("list", list);
self.resetSelected();
});
},
deleteTag() {
const self = this;
bootbox.confirm(I18n.t("tagging.delete_confirm"), function(result) {
if (!result) { return; }
self.get("tag").destroyRecord().then(function() {
self.transitionToRoute("tags.index");
}).catch(function() {
bootbox.alert(I18n.t("generic_error"));
});
});
},
changeTagNotification(id) {
const tagNotification = this.get("tagNotification");
tagNotification.update({ notification_level: id });
}
}
});

View file

@ -14,11 +14,15 @@ addBulkButton('archiveTopics', 'archive_topics');
addBulkButton('showNotificationLevel', 'notification_level');
addBulkButton('resetRead', 'reset_read');
addBulkButton('unlistTopics', 'unlist_topics');
addBulkButton('showTagTopics', 'change_tags');
// Modal for performing bulk actions on topics
export default Ember.ArrayController.extend(ModalFunctionality, {
tags: null,
buttonRows: null,
emptyTags: Ember.computed.empty('tags'),
onShow() {
this.set('controllers.modal.modalClass', 'topic-bulk-actions-modal small');
@ -73,6 +77,15 @@ export default Ember.ArrayController.extend(ModalFunctionality, {
},
actions: {
showTagTopics() {
this.set('tags', '');
this.send('changeBulkTemplate', 'bulk-tag');
},
changeTags() {
this.performAndRefresh({type: 'change_tags', tags: this.get('tags')});
},
showChangeCategory() {
this.send('changeBulkTemplate', 'modal/bulk_change_category');
this.set('controllers.modal.modalClass', 'topic-bulk-actions-modal full');

View file

@ -108,6 +108,11 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
return post => this.postSelected(post);
}.property(),
@computed('model.isPrivateMessage')
canEditTags(isPrivateMessage) {
return !isPrivateMessage && this.site.get('can_tag_topics');
},
actions: {
fillGapBefore(args) {

View file

@ -0,0 +1,6 @@
import registerUnbound from 'discourse/helpers/register-unbound';
import renderTag from 'discourse/lib/render-tag';
export default registerUnbound('discourse-tag', function(name, params) {
return new Handlebars.SafeString(renderTag(name, params));
});

View file

@ -0,0 +1,65 @@
import { CANCELLED_STATUS } from 'discourse/lib/autocomplete';
import Category from 'discourse/models/category';
var cache = {};
var cacheTime;
var oldSearch;
function updateCache(term, results) {
cache[term] = results;
cacheTime = new Date();
return results;
}
function searchTags(term, categories, limit) {
return new Ember.RSVP.Promise((resolve) => {
const clearPromise = setTimeout(() => {
resolve(CANCELLED_STATUS);
}, 5000);
const debouncedSearch = _.debounce((q, cats, resultFunc) => {
oldSearch = $.ajax(Discourse.getURL("/tags/filter/search"), {
type: 'GET',
cache: true,
data: { limit: limit, q }
});
var returnVal = CANCELLED_STATUS;
oldSearch.then((r) => {
var tags = r.results.map((tag) => { return { text: tag.text, count: tag.count }; });
returnVal = cats.concat(tags);
}).always(() => {
oldSearch = null;
resultFunc(returnVal);
});
}, 300);
debouncedSearch(term, categories, (result) => {
clearTimeout(clearPromise);
resolve(updateCache(term, result));
});
});
};
export function search(term, siteSettings) {
if (oldSearch) {
oldSearch.abort();
oldSearch = null;
}
if ((new Date() - cacheTime) > 30000) cache = {};
const cached = cache[term];
if (cached) return cached;
const limit = 5;
var categories = Category.search(term, { limit });
var numOfCategories = categories.length;
categories = categories.map((category) => { return { model: category }; });
if (numOfCategories !== limit && siteSettings.tagging_enabled) {
return searchTags(term, categories, limit - numOfCategories);
} else {
return updateCache(term, categories);
}
};

View file

@ -0,0 +1,52 @@
import { replaceSpan } from 'discourse/lib/category-hashtags';
import { TAG_HASHTAG_POSTFIX } from 'discourse/lib/tag-hashtags';
const validTagHashtags = {};
const checkedTagHashtags = [];
const testedClass = 'tag-hashtag-tested';
function updateFound($hashtags, tagValues) {
Ember.run.schedule('afterRender', () => {
$hashtags.each((index, hashtag) => {
const tagValue = tagValues[index];
const link = validTagHashtags[tagValue];
const $hashtag = $(hashtag);
if (link) {
replaceSpan($hashtag, tagValue, link);
} else if (checkedTagHashtags.indexOf(tagValue) !== -1) {
$hashtag.addClass(testedClass);
}
});
});
}
export function linkSeenTagHashtags($elem) {
const $hashtags = $(`span.hashtag:not(.${testedClass})`, $elem);
const unseen = [];
if ($hashtags.length) {
const tagValues = $hashtags.map((_, hashtag) => {
return $(hashtag).text().substr(1).replace(`${TAG_HASHTAG_POSTFIX}`, "");
});
if (tagValues.length) {
_.uniq(tagValues).forEach((tagValue) => {
if (checkedTagHashtags.indexOf(tagValue) === -1) unseen.push(tagValue);
});
}
updateFound($hashtags, tagValues);
}
return unseen;
};
export function fetchUnseenTagHashtags(tagValues) {
return Discourse.ajax("/tags/check", { data: { tag_values: tagValues } })
.then((response) => {
response.valid.forEach((tag) => {
validTagHashtags[tag.value] = tag.url;
});
checkedTagHashtags.push.apply(checkedTagHashtags, tagValues);
});
}

View file

@ -0,0 +1,37 @@
import { h } from 'virtual-dom';
export default function renderTag(tag, params) {
params = params || {};
tag = Handlebars.Utils.escapeExpression(tag);
const classes = ['tag-' + tag, 'discourse-tag'];
const tagName = params.tagName || "a";
const href = tagName === "a" ? " href='" + Discourse.getURL("/tags/" + tag) + "' " : "";
if (Discourse.SiteSettings.tag_style || params.style) {
classes.push(params.style || Discourse.SiteSettings.tag_style);
}
let val = "<" + tagName + href + " class='" + classes.join(" ") + "'>" + tag + "</" + tagName + ">";
if (params.count) {
val += " <span class='discourse-tag-count'>x" + params.count + "</span>";
}
return val;
};
export function tagNode(tag, params) {
const classes = ['tag-' + tag, 'discourse-tag'];
const tagName = params.tagName || "a";
if (Discourse.SiteSettings.tag_style || params.style) {
classes.push(params.style || Discourse.SiteSettings.tag_style);
}
if (tagName === 'a') {
const href = Discourse.getURL(`/tags/${tag}`);
return h(tagName, { className: classes.join(' '), attributes: { href } }, tag);
} else {
return h(tagName, { className: classes.join(' ') }, tag);
}
}

View file

@ -0,0 +1 @@
export const TAG_HASHTAG_POSTFIX = '::tag';

View file

@ -28,12 +28,14 @@ const CLOSED = 'closed',
archetype: 'archetypeId',
target_usernames: 'targetUsernames',
typing_duration_msecs: 'typingTime',
composer_open_duration_msecs: 'composerTime'
composer_open_duration_msecs: 'composerTime',
tags: 'tags'
},
_edit_topic_serializer = {
title: 'topic.title',
categoryId: 'topic.category.id'
categoryId: 'topic.category.id',
tags: 'topic.tags'
};
const Composer = RestModel.extend({

View file

@ -72,6 +72,24 @@ const Topic = RestModel.extend({
return this.store.createRecord('postStream', {id: this.get('id'), topic: this});
}.property(),
@computed('tags')
visibleListTags(tags) {
if (!tags || !Discourse.SiteSettings.suppress_overlapping_tags_in_list) {
return tags;
}
const title = this.get('title');
const newTags = [];
tags.forEach(function(tag){
if (title.toLowerCase().indexOf(tag) === -1 || Discourse.SiteSettings.staff_tags.indexOf(tag) !== -1) {
newTags.push(tag);
}
});
return newTags;
},
replyCount: function() {
return this.get('posts_count') - 1;
}.property('posts_count'),

View file

@ -118,4 +118,16 @@ export default function() {
this.resource('queued-posts', { path: '/queued-posts' });
this.route('full-page-search', {path: '/search'});
this.resource('tags', function() {
this.route('show', {path: '/:tag_id'});
this.route('showCategory', {path: '/c/:category/:tag_id'});
this.route('showParentCategory', {path: '/c/:parent_category/:category/:tag_id'});
Discourse.Site.currentProp('filters').forEach(filter => {
this.route('show' + filter.capitalize(), {path: '/:tag_id/l/' + filter});
this.route('showCategory' + filter.capitalize(), {path: '/c/:category/:tag_id/l/' + filter});
this.route('showParentCategory' + filter.capitalize(), {path: '/c/:parent_category/:category/:tag_id/l/' + filter});
});
});
}

View file

@ -0,0 +1,12 @@
export default Discourse.Route.extend({
model() {
return this.store.findAll('tag');
},
actions: {
didTransition() {
this.controllerFor("application").set("showFooter", true);
return true;
}
}
});

View file

@ -0,0 +1,137 @@
import Composer from 'discourse/models/composer';
import showModal from "discourse/lib/show-modal";
import { findTopicList } from 'discourse/routes/build-topic-route';
export default Discourse.Route.extend({
navMode: 'latest',
renderTemplate() {
const controller = this.controllerFor('tags.show');
this.render('tags.show', { controller });
},
model(params) {
var tag = this.store.createRecord("tag", { id: Handlebars.Utils.escapeExpression(params.tag_id) }),
f = '';
if (params.category) {
f = 'c/';
if (params.parent_category) { f += params.parent_category + '/'; }
f += params.category + '/l/';
}
f += this.get('navMode');
this.set('filterMode', f);
if (params.category) { this.set('categorySlug', params.category); }
if (params.parent_category) { this.set('parentCategorySlug', params.parent_category); }
if (this.get("currentUser")) {
// If logged in, we should get the tag"s user settings
return this.store.find("tagNotification", tag.get("id")).then(tn => {
this.set("tagNotification", tn);
return tag;
});
}
return tag;
},
afterModel(tag) {
const controller = this.controllerFor('tags.show');
controller.set('loading', true);
const params = controller.getProperties('order', 'ascending');
const categorySlug = this.get('categorySlug');
const parentCategorySlug = this.get('parentCategorySlug');
const filter = this.get('navMode');
if (categorySlug) {
var category = Discourse.Category.findBySlug(categorySlug, parentCategorySlug);
if (parentCategorySlug) {
params.filter = `tags/c/${parentCategorySlug}/${categorySlug}/${tag.id}/l/${filter}`;
} else {
params.filter = `tags/c/${categorySlug}/${tag.id}/l/${filter}`;
}
this.set('category', category);
} else {
params.filter = `tags/${tag.id}/l/${filter}`;
this.set('category', null);
}
return findTopicList(this.store, this.topicTrackingState, params.filter, params, {}).then(function(list) {
controller.set('list', list);
controller.set('canCreateTopic', list.get('can_create_topic'));
if (list.topic_list.tags) {
Discourse.Site.currentProp('top_tags', list.topic_list.tags);
}
controller.set('loading', false);
});
},
titleToken() {
const filterText = I18n.t('filters.' + this.get('navMode').replace('/', '.') + '.title'),
controller = this.controllerFor('tags.show');
if (this.get('category')) {
return I18n.t('tagging.filters.with_category', { filter: filterText, tag: controller.get('model.id'), category: this.get('category.name')});
} else {
return I18n.t('tagging.filters.without_category', { filter: filterText, tag: controller.get('model.id')});
}
},
setupController(controller, model) {
this.controllerFor('tags.show').setProperties({
model,
tag: model,
category: this.get('category'),
filterMode: this.get('filterMode'),
navMode: this.get('navMode'),
tagNotification: this.get('tagNotification')
});
},
actions: {
invalidateModel() {
this.refresh();
},
renameTag(tag) {
showModal("rename-tag", { model: tag });
},
createTopic() {
var controller = this.controllerFor("tags.show"),
self = this;
this.controllerFor('composer').open({
categoryId: controller.get('category.id'),
action: Composer.CREATE_TOPIC,
draftKey: controller.get('list.draft_key'),
draftSequence: controller.get('list.draft_sequence')
}).then(function() {
// Pre-fill the tags input field
if (controller.get('model.id')) {
var c = self.controllerFor('composer').get('model');
c.set('tags', [controller.get('model.id')]);
}
});
},
didTransition() {
this.controllerFor("tags.show")._showFooter();
return true;
},
willTransition(transition) {
if (!Discourse.SiteSettings.show_filter_by_tag) { return true; }
if ((transition.targetName.indexOf("discovery.parentCategory") !== -1 ||
transition.targetName.indexOf("discovery.category") !== -1) && !transition.queryParams.allTags ) {
this.transitionTo("/tags" + transition.intent.url + "/" + this.currentModel.get("id"));
}
return true;
}
}
});

View file

@ -0,0 +1,5 @@
<p>{{i18n "topics.bulk.choose_new_tags"}}</p>
<p>{{tag-chooser tags=tags}}</p>
{{d-button action="changeTags" disabled=emptyTags label="topics.bulk.change_tags"}}

View file

@ -0,0 +1,13 @@
<div class='autocomplete'>
<ul>
{{#each option in options}}
<li>
{{#if option.model}}
<a href>{{category-link option.model allowUncategorized="true" link="false"}}</a>
{{else}}
<a href>{{fa-icon 'tag'}}{{option.text}} x {{option.count}}</a>
{{/if}}
</li>
{{/each}}
</ul>
</div>

View file

@ -4,6 +4,10 @@
{{category-drop category=secondCategory parentCategory=firstCategory categories=childCategories subCategory="true" noSubcategories=noSubcategories}}
{{/if}}
{{#if siteSettings.tagging_enabled}}
{{tag-drop firstCategory=firstCategory secondCategory=secondCategory tagId=tagId}}
{{/if}}
{{plugin-outlet "bread-crumbs-right" tagName="li"}}
<div class='clear'></div>

View file

@ -0,0 +1 @@
{{tagRecord.id}}

View file

@ -0,0 +1,21 @@
{{#if showTagDropdown}}
{{#if tagId}}
<a href {{action "expand"}} {{bind-attr class=":badge-category tagClass"}}>{{tagId}}</a>
{{else}}
<a href {{action "expand"}} {{bind-attr class=":badge-category tagClass :home"}}>{{allTagsLabel}}</a>
{{/if}}
{{#if tags}}
<a href {{action "expand"}} class={{dropdownButtonClass}}><i class={{iconClass}}></i></a>
<section {{bind-attr class="expanded::hidden :category-dropdown-menu :chooser"}}>
<div class='cat'><a {{bind-attr href=allTagsUrl}} data-drop-close="true" class='badge-category home'>{{allTagsLabel}}</a></div>
{{#if renderTags}}
{{#each t in tags}}
<div class='cat'>
{{tag-drop-link tagId=t category=currentCategory}}
</div>
{{/each}}
{{/if}}
</section>
{{/if}}
{{/if}}

View file

@ -2,5 +2,12 @@
{{bound-category-link topic.category.parentCategory}}
{{/if}}
{{bound-category-link topic.category hideParent=true}}
{{#if siteSettings.tagging_enabled}}
<div class="list-tags">
{{#each t in topic.tags}}
{{discourse-tag t}}
{{/each}}
</div>
{{/if}}
{{plugin-outlet "topic-category"}}

View file

@ -97,6 +97,9 @@
{{#if currentUser}}
<div class='submit-panel'>
{{plugin-outlet "composer-fields-below"}}
{{#if canEditTags}}
{{tag-chooser tags=model.tags tabIndex="4"}}
{{/if}}
<button {{action "save"}} tabindex="5" {{bind-attr class=":btn :btn-primary :create disableSubmit:disabled"}} title="{{i18n 'composer.title'}}">{{{model.saveIcon}}}{{model.saveText}}</button>
<a href {{action "cancel"}} class='cancel' tabindex="6">{{i18n 'cancel'}}</a>

View file

@ -70,6 +70,9 @@
</a>
<div class='search-category'>
{{category-link result.topic.category}}
{{#each result.topic.tags as |tag|}}
{{discourse-tag tag}}
{{/each}}
{{plugin-outlet "full-page-search-category"}}
</div>
</div>

View file

@ -11,6 +11,13 @@
{{#if controller.showTopicPostBadges}}
{{raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl}}
{{/if}}
{{#if topic.tags}}
<div class='discourse-tags'>
{{#each tag in topic.visibleListTags}}
{{discourse-tag tag}}
{{/each}}
</div>
{{/if}}
{{plugin-outlet "topic-list-tags"}}
{{#if expandPinned}}
{{raw "list/topic-excerpt" topic=topic}}

View file

@ -27,6 +27,14 @@
</div>
{{/unless}}
{{#if context.topic.tags}}
<div class='discourse-tags'>
{{#each tag in context.topic.visibleListTags}}
{{discourse-tag tag}}
{{/each}}
</div>
{{/if}}
{{plugin-outlet "topic-list-tags"}}
<div class="pull-right">

View file

@ -73,6 +73,19 @@
</div>
{{/if}}
{{/if}}
{{#if model.tags_changes}}
<div class='row'>
{{i18n "tagging.changed"}}
{{#each previousTagChanges as |t|}}
{{discourse-tag t}}
{{/each}}
&rarr;
&nbsp;
{{#each currentTagChanges as |t|}}
{{discourse-tag t}}
{{/each}}
</div>
{{/if}}
{{plugin-outlet "post-revisions"}}

View file

@ -0,0 +1,13 @@
<div class="modal-body">
<label>
<p>{{i18n "tagging.rename_instructions"}}</p>
{{input value=buffered.id maxlength=siteSettings.max_tag_length}}
</label>
</div>
<div class="modal-footer">
{{d-button class="btn-primary"
action="performRename"
label="tagging.rename_tag"
disabled=renameDisabled}}
</div>

View file

@ -0,0 +1,9 @@
<div class="container list-container">
<div class="row">
<div class="full-width">
<div id='list-area'>
{{outlet}}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,15 @@
<h2>{{i18n "tagging.all_tags"}}</h2>
<div class='tag-sort-options'>
{{i18n "tagging.sort_by"}}
<a {{action "sortByCount"}}>{{i18n "tagging.sort_by_count"}}</a>
<a {{action "sortById"}}>{{i18n "tagging.sort_by_name"}}</a>
</div>
<div class='tag-list'>
{{#each tag in sortedTags}}
<div class='tag-box'>
{{discourse-tag tag.id}} <span class='tag-count'>x {{tag.count}}</span>
</div>
{{/each}}
</div>

View file

@ -0,0 +1,58 @@
<div class="list-controls">
<div class="container">
{{#if tagNotification}}
{{tag-notifications-button action="changeTagNotification"
notificationLevel=tagNotification.notification_level}}
{{/if}}
{{#if showAdminControls}}
{{d-button action="deleteTag" label="tagging.delete_tag" icon="trash-o" class="admin-tag btn-danger"}}
{{d-button action="renameTag" actionParam=tag label="tagging.rename_tag" icon="pencil" class="admin-tag"}}
{{else}}
{{#if canCreateTopic}}
<button id="create-topic" class='btn btn-default' {{action "createTopic"}}><i class='fa fa-plus'></i>{{i18n 'topic.create'}}</button>
{{/if}}
{{/if}}
{{#if showTagFilter}}
{{bread-crumbs categories=categories
category=category
tagId=tag.id
noSubcategories=noSubcategories
hideSubcategories=showingSubcategoryList}}
{{navigation-bar navItems=navItems filterMode=filterMode}}
{{else}}
<h2 class="tag-show-heading">
{{#link-to 'tags'}}{{i18n "tagging.tags"}}{{/link-to}}
{{fa-icon "angle-right"}}
{{discourse-tag-bound tagRecord=tag style="simple"}}
</h2>
{{/if}}
</div>
</div>
<footer class='topic-list-bottom'>
{{conditional-loading-spinner condition=loading}}
{{#unless loading}}
{{#if list.topics}}
{{bulk-select-button selected=selected refreshTarget=controller}}
{{topic-list topics=list.topics
canBulkSelect=canBulkSelect
toggleBulkSelect="toggleBulkSelect"
bulkSelectEnabled=bulkSelectEnabled
selected=selected
showPosters=true
currentUser=currentUser
order=order
ascending=ascending
changeSort="changeSort"}}
{{else}}
<h3>
{{footerMessage}}{{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}}
</h3>
{{/if}}
{{/unless}}
</footer>

View file

@ -23,6 +23,10 @@
{{category-chooser valueAttribute="id" value=buffered.category_id source=buffered.category_id}}
{{/if}}
{{#if canEditTags}}
{{tag-chooser tags=buffered.tags}}
{{/if}}
{{plugin-outlet "edit-topic"}}
{{d-button action="finishedEditingTopic" class="btn-primary btn-small submit-edit" icon="check"}}

View file

@ -0,0 +1,5 @@
import ModalBodyView from 'discourse/views/modal-body';
export default ModalBodyView.extend({
title: I18n.t("tagging.rename_tag")
});

View file

@ -0,0 +1,3 @@
import DiscoveryTopicsView from "discourse/views/discovery-topics";
export default DiscoveryTopicsView;

View file

@ -83,6 +83,10 @@ export default createWidget('hamburger-menu', {
links.push({ route: 'users', className: 'user-directory-link', label: 'directory.title' });
}
if (this.siteSettings.tagging_enabled) {
links.push({ route: 'tags', label: 'tagging.tags' });
}
return links.map(l => this.attach('link', l));
},

View file

@ -3,6 +3,7 @@ import { h } from 'virtual-dom';
import { iconNode } from 'discourse/helpers/fa-icon';
import DiscourseURL from 'discourse/lib/url';
import RawHtml from 'discourse/widgets/raw-html';
import { tagNode } from 'discourse/lib/render-tag';
export default createWidget('header-topic-info', {
tagName: 'div.extra-info-wrapper',
@ -42,6 +43,13 @@ export default createWidget('header-topic-info', {
}
title.push(this.attach('category-link', { category }));
}
if (this.siteSettings.tagging_enabled) {
const tags = topic.get('tags') || [];
if (tags.length) {
title.push(h('div.list-tags', tags.map(tagNode)));
}
}
}
const contents = h('div.title-wrapper', title);

View file

@ -0,0 +1,192 @@
.topic-title-outlet.choose-tags {
margin-left: 25px;
margin-top: 3px;
}
.tag-list {
margin-top: 2em;
}
.tag-box {
display: inline-block;
width: 300px;
margin-bottom: 1em;
float: left;
.discourse-tag {
font-size: 1em;
}
.tag-count {
font-size: 0.9em;
}
}
.extra-info-wrapper {
.list-tags {
padding-top: 5px;
}
.discourse-tag {
-webkit-animation: fadein .7s;
animation: fadein .7s;
}
}
.add-tags .select2 {
margin: 0;
}
$tag-color: scale-color($primary, $lightness: 40%);
.discourse-tag-count {
font-size: 0.8em;
color: $tag-color;
}
.select2-result-label .discourse-tag {
margin-right: 0;
}
.discourse-tag {
padding: 0;
margin: 0 5px 0 0;
color: $tag-color;
&:visited, &:hover {
color: $tag-color;
}
&.box {
background-color: scale-color($primary, $lightness: 90%);
color: scale-color($primary, $lightness: 30%);
padding: 2px 8px;
}
&.simple, &.simple:visited, &.simple:hover {
margin-right: 0px;
color: scale-color($primary, $lightness: 30%);
}
}
.discourse-tags, .list-tags {
.discourse-tag.simple:not(:last-child):after {
content: ", ";
margin-left: 1px;
}
}
.select2-container-multi .select2-choices .select2-search-choice.discourse-tag-select2 {
padding-top: 5px;
-webkit-box-shadow: none;
box-shadow: none;
border: 0;
border-radius: 0;
background-color: transparent;
}
.fps-result .add-full-page-tags {
display: inline-block;
}
.topic-list-item .discourse-tags {
display: block;
font-size: 0.75em;
font-weight: normal;
clear: both;
margin-top: 5px;
.discourse-tag.box {
position:relative;
top: 2px;
}
}
.mobile-view .topic-list-item .discourse-tags {
display: inline-block;
font-size: 0.9em;
margin-top: 0;
.discourse-tag.box {
position:relative;
top: 0;
}
}
.discourse-tag.bullet:before {
content: "\f04d";
font-family: FontAwesome;
color: scale-color($primary, $lightness: 70%);
margin-right: 5px;
font-size: 0.7em;
position:relative;
top: -0.1em;
}
header .discourse-tag {color: $tag-color !important; }
.list-tags {
display: inline;
margin: 0 0 0 5px;
font-size: 0.857em;
}
.tag-chooser {
width: 100%;
margin: 5px 0;
}
.admin-tag {
position: relative;
float: right;
margin-right: 8px;
}
.tag-notification-menu {
float: right;
margin-bottom: 10px;
}
.tag-notification-menu .dropdown-menu {
right: 0;
top: 30px;
bottom: auto;
left: auto;
}
.bullet + .list-tags {
display: block;
}
.bar + .list-tags {
line-height: 1.25;
.discourse-tag {
vertical-align: middle;
}
}
.box + .list-tags {
display: inline-block;
margin: 5px 0 0 5px;
padding-top: 2px;
}
.tag-sort-options a {
text-decoration: underline;
}
.autocomplete {
.fa-tag {
color: dark-light-choose($primary, scale-color($primary, $lightness: 70%));
padding-right: 5px;
}
a {
color: $tag-color;
}
}

View file

@ -0,0 +1,192 @@
require_dependency 'topic_list_responder'
require_dependency 'topics_bulk_action'
require_dependency 'topic_query'
class TagsController < ::ApplicationController
include TopicListResponder
before_filter :ensure_tags_enabled
skip_before_filter :check_xhr, only: [:tag_feed, :show]
before_filter :ensure_logged_in, only: [:notifications, :update_notifications, :update]
before_filter :set_category_from_params, except: [:index, :update, :destroy, :tag_feed, :search, :notifications, :update_notifications]
def index
tag_counts = self.class.tags_by_count(guardian, limit: 300).count
tags = tag_counts.map {|t, c| { id: t, text: t, count: c } }
render json: { tags: tags }
end
Discourse.filters.each do |filter|
define_method("show_#{filter}") do
@tag_id = DiscourseTagging.clean_tag(params[:tag_id])
# TODO PERF: doesn't scale:
topics_tagged = TopicCustomField.where(name: DiscourseTagging::TAGS_FIELD_NAME, value: @tag_id).pluck(:topic_id)
page = params[:page].to_i
query = TopicQuery.new(current_user, build_topic_list_options)
results = query.send("#{filter}_results").where(id: topics_tagged)
if @filter_on_category
category_ids = [@filter_on_category.id] + @filter_on_category.subcategories.pluck(:id)
results = results.where(category_id: category_ids)
end
@list = query.create_list(:by_tag, {}, results)
@list.draft_key = Draft::NEW_TOPIC
@list.draft_sequence = DraftSequence.current(current_user, Draft::NEW_TOPIC)
@list.draft = Draft.get(current_user, @list.draft_key, @list.draft_sequence) if current_user
@list.more_topics_url = list_by_tag_path(tag_id: @tag_id, page: page + 1)
@rss = "tag"
if @list.topics.size == 0 && !TopicCustomField.where(name: DiscourseTagging::TAGS_FIELD_NAME, value: @tag_id).exists?
raise Discourse::NotFound
else
respond_with_list(@list)
end
end
end
def show
show_latest
end
def update
guardian.ensure_can_admin_tags!
new_tag_id = DiscourseTagging.clean_tag(params[:tag][:id])
if current_user.staff?
DiscourseTagging.rename_tag(current_user, params[:tag_id], new_tag_id)
end
render json: { tag: { id: new_tag_id }}
end
def destroy
guardian.ensure_can_admin_tags!
tag_id = params[:tag_id]
TopicCustomField.transaction do
TopicCustomField.where(name: DiscourseTagging::TAGS_FIELD_NAME, value: tag_id).delete_all
UserCustomField.delete_all(name: ::DiscourseTagging.notification_key(tag_id))
StaffActionLogger.new(current_user).log_custom('deleted_tag', subject: tag_id)
end
render json: success_json
end
def tag_feed
discourse_expires_in 1.minute
tag_id = ::DiscourseTagging.clean_tag(params[:tag_id])
@link = "#{Discourse.base_url}/tags/#{tag_id}"
@description = I18n.t("rss_by_tag", tag: tag_id)
@title = "#{SiteSetting.title} - #{@description}"
@atom_link = "#{Discourse.base_url}/tags/#{tag_id}.rss"
query = TopicQuery.new(current_user)
topics_tagged = TopicCustomField.where(name: DiscourseTagging::TAGS_FIELD_NAME, value: tag_id).pluck(:topic_id)
latest_results = query.latest_results.where(id: topics_tagged)
@topic_list = query.create_list(:by_tag, {}, latest_results)
render 'list/list', formats: [:rss]
end
def search
tags = self.class.tags_by_count(guardian, params.slice(:limit))
term = params[:q]
if term.present?
term.gsub!(/[^a-z0-9\.\-\_]*/, '')
term.gsub!("_", "\\_")
tags = tags.where('value like ?', "%#{term}%")
end
tags = tags.count(:value).map {|t, c| { id: t, text: t, count: c } }
render json: { results: tags }
end
def notifications
level = current_user.custom_fields[::DiscourseTagging.notification_key(params[:tag_id])] || 1
render json: { tag_notification: { id: params[:tag_id], notification_level: level.to_i } }
end
def update_notifications
level = params[:tag_notification][:notification_level].to_i
current_user.custom_fields[::DiscourseTagging.notification_key(params[:tag_id])] = level
current_user.save_custom_fields
render json: {notification_level: level}
end
def check_hashtag
tag_values = params[:tag_values].each(&:downcase!)
valid_tags = TopicCustomField.where(name: DiscourseTagging::TAGS_FIELD_NAME, value: tag_values).map do |tag|
{ value: tag.value, url: "#{Discourse.base_url}/tags/#{tag.value}" }
end.compact
render json: { valid: valid_tags }
end
private
def ensure_tags_enabled
raise Discourse::NotFound unless SiteSetting.tagging_enabled?
end
def self.tags_by_count(guardian, opts=nil)
opts = opts || {}
result = TopicCustomField.where(name: DiscourseTagging::TAGS_FIELD_NAME)
.joins(:topic)
.group(:value)
.limit(opts[:limit] || 5)
.order('COUNT(topic_custom_fields.value) DESC')
guardian.filter_allowed_categories(result)
end
def set_category_from_params
slug_or_id = params[:category]
return true if slug_or_id.nil?
parent_slug_or_id = params[:parent_category]
parent_category_id = nil
if parent_slug_or_id.present?
parent_category_id = Category.query_parent_category(parent_slug_or_id)
raise Discourse::NotFound if parent_category_id.blank?
end
@filter_on_category = Category.query_category(slug_or_id, parent_category_id)
raise Discourse::NotFound if !@filter_on_category
guardian.ensure_can_see!(@filter_on_category)
end
def build_topic_list_options
options = {
page: params[:page],
topic_ids: param_to_integer_list(:topic_ids),
exclude_category_ids: params[:exclude_category_ids],
category: params[:category],
order: params[:order],
ascending: params[:ascending],
min_posts: params[:min_posts],
max_posts: params[:max_posts],
status: params[:status],
filter: params[:filter],
state: params[:state],
search: params[:search],
q: params[:q]
}
options[:no_subcategories] = true if params[:no_subcategories] == 'true'
options[:slow_platform] = true if slow_platform?
options
end
end

View file

@ -14,6 +14,15 @@ module TopicsHelper
end
breadcrumb.push url: category.url, name: category.name
end
if (tags = topic.tags).present?
tags.each do |tag|
tag_id = DiscourseTagging.clean_tag(tag)
url = "#{Discourse.base_url}/tags/#{tag_id}"
breadcrumb << {url: url, name: tag}
end
end
Plugin::Filter.apply(:topic_categories_breadcrumb, topic, breadcrumb)
end

View file

@ -6,6 +6,7 @@ require_dependency 'text_sentinel'
require_dependency 'text_cleaner'
require_dependency 'archetype'
require_dependency 'html_prettify'
require_dependency 'discourse_tagging'
class Topic < ActiveRecord::Base
include ActionView::Helpers::SanitizeHelper
@ -1043,6 +1044,11 @@ SQL
builder.exec.first["count"].to_i
end
def tags
result = custom_fields[DiscourseTagging::TAGS_FIELD_NAME]
[result].flatten unless result.blank?
end
private
def update_category_topic_count_by(num)

View file

@ -81,8 +81,11 @@ class TopicList
ft.topic_list = self
end
if TopicList.preloaded_custom_fields.present?
Topic.preload_custom_fields(@topics, TopicList.preloaded_custom_fields)
preload_custom_fields = TopicList.preloaded_custom_fields
preload_custom_fields << DiscourseTagging::TAGS_FIELD_NAME if SiteSetting.tagging_enabled
if preload_custom_fields.present?
Topic.preload_custom_fields(@topics, preload_custom_fields)
end
@topics

View file

@ -1,3 +1,5 @@
require_dependency 'discourse_tagging'
class SiteSerializer < ApplicationSerializer
attributes :default_archetype,
@ -14,7 +16,11 @@ class SiteSerializer < ApplicationSerializer
:user_field_max_length,
:suppressed_from_homepage_category_ids,
:post_action_types,
:topic_flag_types
:topic_flag_types,
:can_create_tag,
:can_tag_topics,
:tags_filter_regexp,
:top_tags
has_many :categories, serializer: BasicCategorySerializer, embed: :objects
has_many :trust_levels, embed: :objects
@ -81,4 +87,26 @@ class SiteSerializer < ApplicationSerializer
UserField.max_length
end
def can_create_tag
SiteSetting.tagging_enabled && scope.can_create_tag?
end
def can_tag_topics
SiteSetting.tagging_enabled && scope.can_tag_topics?
end
def include_tags_filter_regexp?
SiteSetting.tagging_enabled
end
def tags_filter_regexp
DiscourseTagging::TAGS_FILTER_REGEXP.source
end
def include_top_tags?
SiteSetting.tagging_enabled && SiteSetting.show_filter_by_tag
end
def top_tags
DiscourseTagging.top_tags
end
end

View file

@ -9,7 +9,8 @@ class TopicListItemSerializer < ListableTopicSerializer
:op_like_count,
:pinned_globally,
:bookmarked_post_numbers,
:liked_post_numbers
:liked_post_numbers,
:tags
has_many :posters, serializer: TopicPosterSerializer, embed: :objects
has_many :participants, serializer: TopicPosterSerializer, embed: :objects
@ -63,4 +64,11 @@ class TopicListItemSerializer < ListableTopicSerializer
object.association(:first_post).loaded?
end
def include_tags?
SiteSetting.tagging_enabled
end
def tags
object.tags
end
end

View file

@ -6,7 +6,8 @@ class TopicListSerializer < ApplicationSerializer
:draft_key,
:draft_sequence,
:for_period,
:per_page
:per_page,
:tags
has_many :topics, serializer: TopicListItemSerializer, embed: :objects
@ -22,4 +23,11 @@ class TopicListSerializer < ApplicationSerializer
object.more_topics_url.present? && (object.topics.size == object.per_page)
end
def include_tags?
SiteSetting.tagging_enabled && SiteSetting.show_filter_by_tag
end
def tags
DiscourseTagging.top_tags
end
end

View file

@ -33,7 +33,8 @@ class TopicViewSerializer < ApplicationSerializer
:word_count,
:deleted_at,
:pending_posts_count,
:user_id
:user_id,
:tags
attributes :draft,
:draft_key,

View file

@ -2,6 +2,14 @@
<%= server_plugin_outlet "topic_list_header" %>
<%- if SiteSetting.tagging_enabled && @tag_id %>
<h1>
<%= link_to "#{Discourse.base_url}/t/#{@tag_id}", itemprop: 'item' do %>
<span itemprop='name'><%= @tag_id %></span>
<% end %>
</h1>
<% end %>
<% if @category %>
<h1>
<% if @category.parent_category %>

View file

@ -17,7 +17,19 @@
<% end %>
</div>
<% end %>
<%- if SiteSetting.tagging_enabled && @topic_view.topic.tags.present? %>
<div class='tags'>
<%= t 'js.tagging.tags' %>:
<%- @topic_view.topic.tags.each do |t| %>
<%= t %>
<%- end %>
</div>
<% end %>
<%= server_plugin_outlet "topic_header" %>
<hr>
<%- if include_crawler_content? %>

View file

@ -1052,6 +1052,9 @@ de:
selected:
one: "Du hast <b>ein</b> Thema ausgewählt."
other: "Du hast <b>{{count}}</b> Themen ausgewählt."
change_tags: "Tags ändern"
choose_new_tags: "Wähle neue Tags für diese Themen:"
changed_tags: "Die Tags dieser Themen wurden geändert."
none:
unread: "Du hast alle Themen gelesen."
new: "Es gibt für dich keine neuen Themen."
@ -2204,6 +2207,8 @@ de:
grant_moderation: "Moderation gewähren"
revoke_moderation: "Moderation entziehen"
backup_operation: "Backup läuft"
deleted_tag: "Tag löschen"
renamed_tag: "Tag umbenennen"
screened_emails:
title: "Gefilterte E-Mails"
description: "Wenn jemand ein Konto erstellt, werden die folgenden E-Mail-Adressen überprüft und es wird die Anmeldung blockiert oder eine andere Aktion ausgeführt."
@ -2699,3 +2704,32 @@ de:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Alle Tags"
selector_all_tags: "Alle Tags"
changed: "Tags geändert:"
tags: "Tags"
choose_for_topic: "Wähle optional Tags für dieses Thema"
topics_tagged: "Themen getaggt mit <span class='discourse-tag'>{{tag}}</span>"
delete_tag: "Tag löschen"
delete_confirm: "Bist du sicher, dass du das Tag löschen möchtest?"
rename_tag: "Tag umbenennen"
rename_instructions: "Wähle einen neuen Namen für das Tag:"
sort_by: "Sortieren nach:"
sort_by_count: "Anzahl"
sort_by_name: "Name"
notifications:
watching:
title: "Beobachten"
description: "Du wirst automatisch alle neuen Themen dieses Tags beobachten. Du wirst über jeden neuen Beitrag und jedes neue Thema benachrichtigt. Die Anzahl der ungelesenen und neuen Beiträge wird neben dem Thema erscheinen."
tracking:
title: "Verfolgen"
description: "Du wirst automatisch alle neuen Themen dieses Tags verfolgen. Die Anzahl der ungelesenen und neuen Beiträge wird neben dem Thema erscheinen."
regular:
title: "Normal"
description: "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet."
muted:
title: "Stummgeschaltet"
description: "Du erhältst keine Benachrichtigungen über neue Themen mit diesem Tag und es wird nicht in deiner Liste ungelesener Tags erscheinen."

View file

@ -1164,6 +1164,9 @@ en:
selected:
one: "You have selected <b>1</b> topic."
other: "You have selected <b>{{count}}</b> topics."
change_tags: "Change Tags"
choose_new_tags: "Choose new tags for these topics:"
changed_tags: "The tags of those topics were changed."
none:
unread: "You have no unread topics."
@ -2410,6 +2413,8 @@ en:
grant_moderation: "grant moderation"
revoke_moderation: "revoke moderation"
backup_operation: "backup operation"
deleted_tag: "deleted tag"
renamed_tag: "renamed tag"
screened_emails:
title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."
@ -2704,6 +2709,7 @@ en:
login: "Login"
plugins: "Plugins"
user_preferences: "User Preferences"
tags: "Tags"
badges:
title: Badges
@ -2922,3 +2928,58 @@ en:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "All Tags"
selector_all_tags: "all tags"
changed: "tags changed:"
tags: "Tags"
choose_for_topic: "choose optional tags for this topic"
delete_tag: "Delete Tag"
delete_confirm: "Are you sure you want to delete that tag?"
rename_tag: "Rename Tag"
rename_instructions: "Choose a new name for the tag:"
sort_by: "Sort by:"
sort_by_count: "count"
sort_by_name: "name"
filters:
without_category: "%{filter} %{tag} topics"
with_category: "%{filter} %{tag} topics in %{category}"
notifications:
watching:
title: "Watching"
description: "You will automatically watch all new topics in this tag. You will be notified of all new posts and topics, plus the count of unread and new posts will also appear next to the topic."
tracking:
title: "Tracking"
description: "You will automatically track all new topics in this tag. A count of unread and new posts will appear next to the topic."
regular:
title: "Regular"
description: "You will be notified if someone mentions your @name or replies to your post."
muted:
title: "Muted"
description: "You will not be notified of anything about new topics in this tag, and they will not appear on your unread tab."
topics:
none:
unread: "You have no unread topics."
new: "You have no new topics."
read: "You haven't read any topics yet."
posted: "You haven't posted in any topics yet."
latest: "There are no latest topics."
hot: "There are no hot topics."
bookmarks: "You have no bookmarked topics yet."
top: "There are no top topics."
search: "There are no search results."
bottom:
latest: "There are no more latest topics."
hot: "There are no more hot topics."
posted: "There are no more posted topics."
read: "There are no more read topics."
new: "There are no more new topics."
unread: "There are no more unread topics."
top: "There are no more top topics."
bookmarks: "There are no more bookmarked topics."
search: "There are no more search results."

View file

@ -1052,6 +1052,9 @@ es:
selected:
one: "Has seleccionado <b>1</b> tema."
other: "Has seleccionado <b>{{count}}</b> temas."
change_tags: "Cambiar etiquetas"
choose_new_tags: "Elegir nuevas etiquetas para estos temas:"
changed_tags: "Las etiquetas de estos temas fueron cambiadas."
none:
unread: "No hay temas que sigas y que no hayas leído ya."
new: "No tienes temas nuevos por leer."
@ -2208,6 +2211,8 @@ es:
grant_moderation: "conceder moderación"
revoke_moderation: "revocar moderación"
backup_operation: "operación de copia de seguridad de respaldo"
deleted_tag: "etiqueta eliminada"
renamed_tag: "etiqueta renombrada"
screened_emails:
title: "Correos bloqueados"
description: "Cuando alguien trata de crear una cuenta nueva, los siguientes correos serán revisados y el registro será bloqueado, o alguna otra acción será realizada."
@ -2703,3 +2708,54 @@ es:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Etiquetas"
selector_all_tags: "etiquetas"
changed: "etiquetas cambiadas:"
tags: "Etiquetas"
choose_for_topic: "elegir etiquetas para este tema (opcional)"
delete_tag: "Eliminar etiqueta"
delete_confirm: "¿Seguro que quieres eliminar esta etiqueta?"
rename_tag: "Renombrar etiqueta"
rename_instructions: "Elige un nuevo nombre para la etiqueta:"
sort_by: "Ordenar por:"
sort_by_count: "volumen"
sort_by_name: "nombre"
notifications:
watching:
title: "Vigilar"
description: "Vigilarás automáticamente todos los nuevos temas con esta etiqueta. Se añadirá un contador de posts nuevos y sin leer al lado del tema y además, se te notificará de cada nuevo tema y post."
tracking:
title: "Seguir"
description: "Seguirás automáticamente todos los nuevos temas con esta etiqueta. Se añadirá un contador de posts nuevos y sin leer al lado del tema."
regular:
title: "Normal"
description: "Se te notificará solo si alguien te menciona con tu @usuario o responde a algún post tuyo."
muted:
title: "Silenciar"
description: "No se te notificará de nuevos temas con esta etiqueta, ni aparecerán en tu pestaña de temas no leídos."
topics:
none:
unread: "No tienes temas sin leer."
new: "No hay temas nuevos."
read: "Aún no has leído ningún tema."
posted: "Aún no has publicado en ningún tema."
latest: "No hay temas recientes."
hot: "No hay temas candentes"
bookmarks: "Aún no has guardado temas en marcadores."
top: "No hay temas top."
search: "No hay resultados resultados de búsqueda."
bottom:
latest: "No hay más temas recientes."
hot: "No hay más temas candentes."
posted: "No hay más temas en los que hayas publicado."
read: "No hay más temas que hayas leído."
new: "No hay más temas nuevos."
unread: "No hay más temas sin leer."
top: "No hay más temas top."
bookmarks: "No hay más temas en marcadores."
search: "No hay más resultados de búsqueda."

View file

@ -918,6 +918,9 @@ fa_IR:
choose_new_category: "یک دسته بندی جدید برای موضوع انتخاب نمایید"
selected:
other: "شما تعداد <b>{{count}}</b> موضوع را انتخاب کرده اید."
change_tags: "تغییر برچسب ها"
choose_new_tags: "انتخاب برچسب های جدید برای این موضوع:"
changed_tags: "برچسب های انتخابی برای این موضوع جایگزین شد."
none:
unread: "موضوع خوانده نشده‌ای ندارید."
new: "شما هیچ موضوع تازه‌ای ندارید"
@ -1956,6 +1959,8 @@ fa_IR:
change_category_settings: "تغییر تنظیمات دسته بندی"
delete_category: "حذف دسته بندی"
create_category: "ساخت دسته بندی"
deleted_tag: "حذف برچسب"
renamed_tag: "تغییر نام برچسب"
screened_emails:
title: "ایمیل ها نمایش داده شده"
description: "وقتی کسی سعی می کند یک حساب جدید ایجاد کند، از آدرس ایمیل زیر بررسی و ثبت نام مسدود خواهد شد، و یا برخی از اقدام های دیگر انجام می شود."
@ -2430,3 +2435,27 @@ fa_IR:
</form>
</p>
tagging:
all_tags: "تمام پرچسب ها"
changed: "برچسب های تغییر یافته:"
tags: "برچسب ها"
choose_for_topic: "انتخاب برچسب اختیاری برای این موضوع"
delete_tag: "حذف برچسب"
delete_confirm: "آیا مطمئنید که می خواهید این برچسب را خذف کنید?"
rename_tag: "تغییر نام برچسب"
rename_instructions: "یک نام جدید برای برچسب انتخاب نمایید:"
notifications:
watching:
title: "تماشا"
description: "شما به صورت خودکار تمام نوشته های این برچسب را مشاهده خواهید کرد، تمام نوشته ها به شما اطلاع رسانی خواهد شد.و تعداد نوشته های خوانده نشده در جلوی موضوعات نشان داده خواهد شد."
tracking:
title: "پی گیری"
description: "شما به صورت خودکار تمام نوشته های این برچسب را پی گیری خواهید کرد، تمام نوشته های خوانده نشده جلوی موضوعات نشان داده خواهد شد."
regular:
title: "منظم"
description: "به شما اطلاع داده خواهد شد اگر کسی به شما اششاره@name یا به نوشته شما پاسخ دهند."
muted:
title: "بی صدا"
description: "به شما هیچ چیزی در باره نوشته های این تگ اطلاع رسانی نخواهد شد."

View file

@ -1019,6 +1019,9 @@ fi:
selected:
one: "Olet valinnut <b>yhden</b> ketjun."
other: "Olet valinnut <b>{{count}}</b> ketjua."
change_tags: "Muuta tagit"
choose_new_tags: "Valitse uudet tagit näille aiheille:"
changed_tags: "Aiheiden tagit on vaihdettu."
none:
unread: "Sinulla ei ole lukemattomia ketjuja."
new: "Sinulla ei ole uusia ketjuja."
@ -2166,6 +2169,8 @@ fi:
grant_moderation: "myönnä valvojan oikeudet"
revoke_moderation: "peru valvojan oikeudet"
backup_operation: "varmuuskopiointi"
deleted_tag: "poistettu tagi"
renamed_tag: "uudelleennimetty tagi"
screened_emails:
title: "Seulottavat sähköpostiosoitteet"
description: "Uuden käyttäjätunnuksen luonnin yhteydessä annettua sähköpostiosoitetta verrataan alla olevaan listaan ja tarvittaessa tunnuksen luonti joko estetään tai suoritetaan muita toimenpiteitä."
@ -2656,3 +2661,27 @@ fi:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Kaikki tagit"
changed: "muutetut tagit:"
tags: "Tagit"
choose_for_topic: "valitse sopivat tagit aiheelle"
topics_tagged: "Aiheet tagattu <span class='discourse-tag'>{{tag}}</span>"
delete_tag: "Poista tagi"
delete_confirm: "Haluatko varmasti poistaa tagin?"
rename_tag: "Uudelleennimeä tagi"
rename_instructions: "Valitse tagin uusi nimi:"
notifications:
watching:
title: "Tarkkaile"
description: "Saat ilmoituksen kaikista uusista viesteistä ja ketjuista jotka käyttää tätä tagia. Uusien ja lukemattomien määrä näkyy ketjun yhteydessä."
tracking:
title: "Seuraa"
description: "Seuraat automaattisesti tämän tagin ketjuja ja viestejä. Uusien ja lukemattomien määrä näkyy ketjun yhteydessä."
regular:
title: "Tavallinen"
description: "Saat ilmoituksen kun joku mainitsee @nimesi tai vastaa ketjuun."
muted:
title: "Vaimenna"
description: "Et saa ilmoituksia, eikä se näy lukemattomissa."

View file

@ -1018,6 +1018,9 @@ fr:
selected:
one: "Vous avez sélectionné <b>1</b> sujet."
other: "Vous avez sélectionné <b>{{count}}</b> sujets."
change_tags: "Modifier les tags"
choose_new_tags: "Choisir de nouveaux tags pour ces sujets :"
changed_tags: "Les tags de ces sujets ont été modifiés."
none:
unread: "Vous n'avez aucun sujet non lu."
new: "Vous n'avez aucun nouveau sujet."
@ -2164,6 +2167,8 @@ fr:
grant_moderation: "Accorder les droits de modération"
revoke_moderation: "Révoquer les droits de modération"
backup_operation: "sauvegarde"
deleted_tag: "tag supprimé"
renamed_tag: "tag renommé"
screened_emails:
title: "Courriels affichés"
description: "Lorsque quelqu'un essaye de créé un nouveau compte, les adresses de courriel suivantes seront vérifiées et l'inscription sera bloquée, ou une autre action sera réalisée."
@ -2649,3 +2654,26 @@ fr:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Tous les tags"
changed: "tags modifiés:"
tags: "Tags"
choose_for_topic: "choisissez des tags pour ce sujet (optionnel)"
delete_tag: "Supprimer ce tag"
delete_confirm: "Êtes-vous sûr de vouloir supprimer ce tag ?"
rename_tag: "Renommer ce tag"
rename_instructions: "Choisissez un nouveau nom pour le tag :"
notifications:
watching:
title: "Surveiller"
description: "Vous surveillerez automatiquement les nouveaux sujets portant ce tag. Vous serez averti de tous les nouveaux messages et sujets. De plus, le nombre de messages non lus et nouveaux apparaîtra en regard du sujet."
tracking:
title: "Suivre"
description: "Vous suivrez automatiquement les nouveaux sujets portant ce tag. Le nombre de messages non lus et nouveaux apparaîtra en regard du sujet."
regular:
title: "Normal"
description: "Vous serez averti si quelqu'un mentionne votre @pseudo ou répond à votre message."
muted:
title: "Silencieux"
description: "Vous ne recevrez aucune notification des sujets portant ce tag, et ils n'apparaîtront pas dans votre onglet non lus."

View file

@ -899,6 +899,9 @@ nb_NO:
selected:
one: "Du har valgt <b>1</b> emne."
other: "Du har valgt <b>{{count}}</b> emner."
change_tags: "Endre emneord"
choose_new_tags: "Velg nye emneord for disse emnene:"
changed_tags: "Emneord for disse emnene ble endret."
none:
unread: "Du har ingen uleste emner å lese."
new: "Du har ingen nye emner å lese."
@ -1950,6 +1953,8 @@ nb_NO:
roll_up: "rull opp IP-blokker"
delete_category: "slett kategori"
create_category: "opprett kategori"
deleted_tag: "Slettet emneord"
renamed_tag: "Endret emneord"
screened_emails:
title: "Kontrollerte e-poster"
description: "Når noen forsøker å lage en ny konto, vil de følgende e-postadressene bli sjekket, og registreringen vil bli blokkert, eller en annen handling vil bli utført."
@ -2374,3 +2379,27 @@ nb_NO:
name: Annet
posting:
name: Posting
tagging:
all_tags: "Alle emneord"
changed: "emneord endret:"
tags: "Emneord"
choose_for_topic: "velg ekstra emneord for dette emnet"
delete_tag: "Slett emneord"
delete_confirm: "Er du sikker påat du ønsker å slette dette emneordet?"
rename_tag: "Endre emneord"
rename_instructions: "Velg et nytt navn på dette emneordet:"
notifications:
watching:
title: "Følger"
description: "Du vil automatisk følge alle nye emner med dette emneordet. Du vil bli varslet om alle nye innlegg og emner samt at antallet uleste og nye innlegg vil vises sammen med emneoppføringen."
tracking:
title: "Følger"
description: "Du vil automatisk følge alle nye emner med dette emneordet. Antallet uleste og nye innlegg vil vises sammen med emneoppføringen."
regular:
title: "Vanlig"
description: "Du vil bli varslet om noen nevnet ditt @navn eller svarer på din post."
muted:
title: "Dempet"
description: "Du vil ikke bli varslet om noe vedrørende disse emneneordene og de vil ikke vises i din ulest-liste."

View file

@ -2699,3 +2699,9 @@ pl_PL:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Wszystkie tagi"
changed: "zmienione tagi:"
tags: "Tagi"
choose_for_topic: "wybierz opcjonalne tagi dla tego tematu"
topics_tagged: "Tematy otagowane jako <span class='discourse-tag'>{{tag}}</span>"

View file

@ -1052,6 +1052,9 @@ pt:
selected:
one: "Selecionou <b>1</b> tópico."
other: "Selecionou <b>{{count}}</b> tópicos."
change_tags: "Alterar etiquetas"
choose_new_tags: "Escolher novas etiquetas para estes tópicos:"
changed_tags: "As etiquetas desses tópicos foram alteradas."
none:
unread: "Tem tópicos não lidos."
new: "Não tem novos tópicos."
@ -2208,6 +2211,8 @@ pt:
grant_moderation: "conceder moderação"
revoke_moderation: "revogar moderação"
backup_operation: "operação de cópia de segurança"
deleted_tag: "etiqueta apagada"
renamed_tag: "etiqueta renomeada"
screened_emails:
title: "Emails Filtrados"
description: "Quando alguém tenta criar uma nova conta, os seguintes endereços de email serão verificados e o registo será bloqueado, ou outra ação será executada."
@ -2703,3 +2708,25 @@ pt:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Todas as etiquetas"
changed: "etiquetas alteradas:"
tags: "Etiquetas"
choose_for_topic: "escolher etiqetas opcionais para este tópico"
delete_tag: "Apagar etiqueta"
delete_confirm: "Tem a certeza que quer apagar esta etiqueta?"
rename_tag: "Renomear Etiqueta"
rename_instructions: "Escolher um novo nome para a etiqueta:"
notifications:
watching:
title: "A vigiar"
description: "Irá automaticamente vigiar todos os novos tópicos com esta etiqueta. Será notoficado de todas as novas mensagens e tópicos."
tracking:
title: "A acompanhar"
description: "Irá automaticamente acompanhar todos os novos tópcicos com esta etiqueta"
regular:
title: "Habitual"
description: "Será notoficado se mencionarem o seu @nome ou se alguém lhe responder"
muted:
title: "Silenciado"
description: "Não será notoficado sobre nenhum tópico com esta etiqueta"

View file

@ -1049,6 +1049,9 @@ pt_BR:
selected:
one: "Você selecionou <b>1</b> tópico."
other: "Você selecionou <b>{{count}}</b> tópicos."
change_tags: "Alterar Tags"
choose_new_tags: "Escolha novas tags para estes tópicos:"
changed_tags: "As tags desses tópicos foram alteradas."
none:
unread: "Não há nenhum tópico não lido."
new: "Não há tópicos novos."
@ -2148,6 +2151,8 @@ pt_BR:
block_user: "bloquear usuário"
unblock_user: "desbloquear usuário"
backup_operation: "operação de backup"
deleted_tag: "tag removida"
renamed_tag: "tag renomeada"
screened_emails:
title: "Emails Filtrados"
description: "Quando alguém tenta cria uma nova conta, os seguintes endereços de email serão verificados e o registro será bloqueado, ou outra ação será executada."
@ -2628,3 +2633,52 @@ pt_BR:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Todas as Tags"
selector_all_tags: "todas as tags"
changed: "tags alteradas:"
tags: "Tags"
choose_for_topic: "escolha tags opcionais para este tópico"
delete_tag: "Remover Tag"
delete_confirm: "Você tem certeza que quer remover essa tag?"
rename_tag: "Renomear Tag"
rename_instructions: "Escolha um novo nome para a tag:"
sort_by: "Ordenar por:"
sort_by_count: "número"
sort_by_name: "nome"
notifications:
watching:
title: "Observando"
description: "Você vai observar automaticamente todos os novos tópicos dessa tag. Você será notificado de todas as novas mensagens e tópicos, e uma contagem de novas respostas será mostrada ao lado do tópico."
tracking:
title: "Monitorando"
description: "Você vai monitorar automaticamente todos os novos tópicos dessa tag. Uma contagem de novas respostas será mostrada ao lado do tópico."
regular:
title: "Normal"
description: "Você será notificado se alguém mencionar o seu @nome ou responder à sua mensagem."
muted:
title: "Silenciado"
description: "Você nunca será notificado sobre novos tópicos nessa tag, e eles não aparecerão na sua aba não lidas."
topics:
none:
unread: "Você não possui tópicos não lidos."
new: "Você não possui novos tópicos."
read: "Você não leu nenhum tópico ainda."
posted: "Você não postou em nenhum tópico ainda."
latest: "Não há tópicos mais recentes."
hot: "Não há tópicos quentes."
bookmarks: "Você não possui tópicos favoritos ainda."
top: "Não há melhores tópicos."
search: "Nenhum resultado encontrado."
bottom:
latest: "Não há mais tópicos mais recentes."
hot: "Não há mais tópicos quentes."
posted: "Não há mais tópicos postados."
read: "Não há mais tópicos lidos."
new: "Não há mais tópicos novos."
unread: "Não há mais tópicos não lidos."
top: "Não há mais melhores tópicos."
bookmarks: "Não há mais tópicos favoritos."
search: "Não há mais resultados de busca."

View file

@ -1098,6 +1098,9 @@ ru:
few: "Вы выбрали <b>{{count}}</b> темы."
many: "Вы выбрали <b>{{count}}</b> тем."
other: "Вы выбрали <b>{{count}}</b> тем."
change_tags: "Изменить теги"
choose_new_tags: "Выберите новые теги для этих тем:"
changed_tags: "Теги изменены."
none:
unread: "У вас нет непрочитанных тем."
new: "У вас нет новых тем."
@ -2303,6 +2306,8 @@ ru:
revoke_admin: "отозваны права администратора"
grant_moderation: "выданы права модератора"
revoke_moderation: "отозваны права модератора"
deleted_tag: "удалить тег"
renamed_tag: "переименовать тег"
screened_emails:
title: "Почтовые адреса"
description: "Когда кто-то создает новую учетную запись, проверяется данный почтовый адрес и регистрация блокируется или производятся другие дополнительные действия."
@ -2793,3 +2798,30 @@ ru:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Все теги"
selector_all_tags: "все теги"
changed: "теги изменены:"
tags: "Теги"
choose_for_topic: "выберите теги для темы"
delete_tag: "Удалить тег"
delete_confirm: "Удалить тег?"
rename_tag: "Переименовать тег"
rename_instructions: "Выберите новое имя для тега:"
sort_by: "Сортировать по:"
sort_by_count: "количеству"
sort_by_name: "имени"
notifications:
watching:
title: "Наблюдать"
description: "Уведомлять о каждой новой теме с этим тегом и показывать счетчик новых непрочитанных ответов."
tracking:
title: "Следить"
description: "Количество непрочитанных сообщений появится рядом с названием новых тем с этим тегом."
regular:
title: "Уведомлять"
description: "Вам придет уведомление, только если кто-нибудь упомянет ваш @псевдоним или ответит на ваше сообщение."
muted:
title: "Без уведомлений"
description: "Не уведомлять об изменениях в новых темах с этим тегом и скрыть их из непрочитанных."

View file

@ -991,6 +991,9 @@ tr_TR:
choose_new_category: "Konular için yeni bir kategori seçin:"
selected:
other: "<b>{{count}}</b> konu seçtiniz."
change_tags: "Etiketleri değiştir"
choose_new_tags: "Bu konular için yeni etiket seç:"
changed_tags: "Seçtğiniz konular için etiketler değiştirildi."
none:
unread: "Okunmamış konunuz yok."
new: "Yeni konunuz yok."
@ -2081,6 +2084,8 @@ tr_TR:
grant_moderation: "moderasyon yetkisi ver"
revoke_moderation: "moderasyon yetkisini kaldır"
backup_operation: "yedek operasyonu"
deleted_tag: "silinmiş etiket"
renamed_tag: "yeniden adlandırılmış etiket"
screened_emails:
title: "Taranmış E-postalar"
description: "Biri yeni bir hesap oluşturmaya çalıştığında, aşağıdaki e-posta adresleri kontrol edilecek ve kayıt önlenecek veya başka bir aksiyon alınacak."
@ -2557,3 +2562,26 @@ tr_TR:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "Tüm etiketler"
changed: "Etiketler değişti:"
tags: "Etiketler"
choose_for_topic: "Bu konu için opsiyonel olarak bir etiket seçin"
delete_tag: "Etiketi sil"
delete_confirm: "Bu etiketi kaldırmak istediğinize emin misiniz?"
rename_tag: "Etiketi yeniden adlandır"
rename_instructions: "Etiket için yeni bir ad girin:"
notifications:
watching:
title: "Gözleniyor"
description: "Bu etikette ki her yeni gönderi için bir bildirim alacaksınız. Okunmamış ve yeni gönderilerin sayısı konunun yanında belirecek."
tracking:
title: "Takip Ediliyor"
description: "Bu etikette ki her yeni gönderi takip edilecek. Okunmamış ve yeni gönderilerin sayısı konunun yanında belirecek."
regular:
title: "Standart"
description: "Biri @isim şeklinde sizden bahsederse ya da gönderinize cevap verirse bildirim alacaksınız."
muted:
title: "Susturuldu"
description: "Bu etiket okunmamışlar sekmenizde belirmeyecek, ve hakkında hiç bir bildirim almayacaksınız."

View file

@ -1005,6 +1005,9 @@ zh_CN:
choose_new_category: "为主题选择新分类:"
selected:
other: "你已经选择了 <b>{{count}}</b>个主题"
change_tags: "更改标签"
choose_new_tags: "为这些主题选择新标签:"
changed_tags: "这些主题的标签已被修改。"
none:
unread: "你没有未读主题。"
new: "你没有新主题可读。"
@ -2100,6 +2103,8 @@ zh_CN:
grant_moderation: "授予版主权限"
revoke_moderation: "撤销版主权限"
backup_operation: "备份操作"
deleted_tag: "deleted tag"
renamed_tag: "renamed tag"
screened_emails:
title: "被屏蔽的邮件地址"
description: "当有人试图用以下邮件地址注册时,将受到阻止或其它系统操作。"
@ -2578,3 +2583,52 @@ zh_CN:
<button class="btn btn-primary">Google</button>
</form>
</p>
tagging:
all_tags: "全部标签"
selector_all_tags: "所有标签"
changed: "标签更改:"
tags: "标签"
choose_for_topic: "为主题选择可选标签"
delete_tag: "删除标签"
delete_confirm: "你确定要删除该标签?"
rename_tag: "重命名标签"
rename_instructions: "为标签选择一个新的名字:"
sort_by: "排列顺序:"
sort_by_count: "数量"
sort_by_name: "名字"
notifications:
watching:
title: "关注"
description: "你能自动关注该标签下的所有主题。一旦与这个标签有关的新主题和新帖子发表,你都会收到通知。未读贴和新帖子的数量将出现在主题列表中每个主题的标题后。"
tracking:
title: "追踪"
description: "你能自动追踪这个标签下的所有新主题。未读贴和新帖子的数量将出现在主题列表中每个主题的标题后。"
regular:
title: "常规"
description: "当有人@你或者回复你的帖子时,你才会收到通知。"
muted:
title: "防打扰"
description: "你不会收到该标签下的新主题的任何通知,也不会在你的未阅选项卡中显示。"
topics:
none:
unread: "你没有未读主题。"
new: "你没有近期主题。"
read: "你还未阅读任何主题。"
posted: "你还未在任何主题中回复。"
latest: "没有最新主题。"
hot: "没有热门主题。"
bookmarks: "你还没有加上书签的主题。"
top: "没有热门主题。"
search: "搜索无结果。"
bottom:
latest: "没有更多最新主题了。"
hot: "没有更多热门主题了。"
posted: "没有更多发表的主题了。"
read: "没有更多读过的主题了。"
new: "没有更多近期主题了。"
unread: "没有更多未读主题了。"
top: "没有更多热门主题了。"
bookmarks: "没有更多加上书签的主题了。"
search: "没有更多搜索结果了。"

View file

@ -1101,6 +1101,19 @@ de:
default_categories_watching: "Liste der standardmäßig beobachteten Kategorien."
default_categories_tracking: "Liste der standardmäßig gefolgten Kategorien."
default_categories_muted: "Liste der standardmäßig stummgeschalteten Kategorien."
tagging_enabled: "Nutzern erlauben Themen zu taggen?"
min_trust_to_create_tag: "Minimale Vertrauensstufe um ein Tag zu erstellen."
max_tags_per_topic: "Maximale Anzahl von Tags, die einem Thema zugeordnet werden können."
max_tag_length: "Maximale Anzahl von Zeichen, die in einem Tag verwendet werden können."
max_tag_search_results: "Maximale Anzahl von Ergebnissen, die bei der Suche nach Tags angezeigt werden."
show_filter_by_tag: "Dropdown-Liste zum Filtern von Theman nach Tag anzeigen."
max_tags_in_filter_list: "Maximale Anzahl von Tags in der Dropdown-Liste. Die meist genutzten Tags werden angezeigt."
tags_sort_alphabetically: "Anzeige der Tags in alphabetischer Reihenfolge. Standard ist die Sortierung nach Beliebtheit."
tag_style: "Visuelle Darstellung der Tag-Schildchen."
staff_tags: "Eine Liste von Tags, die nur von Mitarbeitern verwendet werden können"
min_trust_level_to_tag_topics: "Minimal benötigte Vertrauensstufe zum Taggen von Themen"
suppress_overlapping_tags_in_list: "Verstecke Tags in Listen, wenn diese mit dem Titel überlappen"
remove_muted_tags_from_latest: "Zeige Themen mit stummgeschalteten Tags nicht in der Liste der aktuellen Themen an."
errors:
invalid_email: "Ungültige E-Mail-Ad­res­se"
invalid_username: "Es gibt keinen Benutzer mit diesem Benutzernamen."
@ -2048,3 +2061,9 @@ de:
activemodel:
errors:
<<: *errors
tags:
staff_tag_disallowed: "Das Tag \"%{tag}\" kann nur von Mitarbeitern hinzugefügt werden."
staff_tag_remove_disallowed: "Das Tag \"%{tag}\" kann nur von Mitarbeitern entfernt werden."
rss_by_tag: "Themen getaggt mit %{tag}"
rss_description:
tag: "Getaggte Themen"

View file

@ -1317,6 +1317,20 @@ en:
default_categories_tracking: "List of categories that are tracked by default."
default_categories_muted: "List of categories that are muted by default."
tagging_enabled: "Enable tags on topics?"
min_trust_to_create_tag: "The minimum trust level required to create a tag."
max_tags_per_topic: "The maximum tags that can be applied to a topic."
max_tag_length: "The maximum amount of characters that can be used in a tag."
max_tag_search_results: "When searching for tags, the maxium number of results to show."
show_filter_by_tag: "Show a dropdown to filter a topic list by tag."
max_tags_in_filter_list: "Maximum number of tags to show in the filter dropdown. The most used tags will be shown."
tags_sort_alphabetically: "Show tags in alphabetical order. Default is to show in order of popularity."
tag_style: "Visual style for tag badges."
staff_tags: "A list of tags that can only be applied by staff members"
min_trust_level_to_tag_topics: "Minimum trust level required to tag topics"
suppress_overlapping_tags_in_list: "Hide tags from list views, if they overlap with title"
remove_muted_tags_from_latest: "Don't show topics tagged with muted tags in the latest topic list."
errors:
invalid_email: "Invalid email address."
invalid_username: "There's no user with that username."
@ -3006,3 +3020,10 @@ en:
topic_invite:
user_exists: "Sorry, that user has already been invited. You may only invite a user to a topic once."
tags:
staff_tag_disallowed: "The tag \"%{tag}\" may only be applied by staff."
staff_tag_remove_disallowed: "The tag \"%{tag}\" may only be removed by staff."
rss_by_tag: "Topics tagged %{tag}"
rss_description:
tag: "Tagged topics"

View file

@ -1110,6 +1110,19 @@ es:
default_categories_watching: "Lista de categorías que están vigiladas por defecto."
default_categories_tracking: "Lista de categorías que están seguidas por defecto"
default_categories_muted: "Lista de categorías que están silenciadas por defecto."
tagging_enabled: "¿Permitir a los usuarios etiquetar temas?"
min_trust_to_create_tag: "Mínimo nivel de confianza requerido para crear una etiqueta."
max_tags_per_topic: "Número máximo de etiquetas que pueden añadirse a un tema."
max_tag_length: "Longitud máxima de caracteres que puede tener el nombre de una etiqueta."
max_tag_search_results: "Máximo número de resultados a mostrar al buscar etiquetas."
show_filter_by_tag: "Mostrar un desplegable para filtrar la lista de temas por etiqueta."
max_tags_in_filter_list: "Máximo número de etiquetas a mostrar en el desplegable. Se mostrarán las más usadas."
tags_sort_alphabetically: "Mostrar etiquetas en orden alfabético. Por defecto se muestran por popularidad."
tag_style: "Estilo visual de los distintivos de etiqueta."
staff_tags: "Una lista de etiquetas que sólo podrá ser aplicada por administradores o moderadores"
min_trust_level_to_tag_topics: "Mínimo nivel de confianza requerido para etiquetar temas"
suppress_overlapping_tags_in_list: "Ocultar etiquetas de la vista de listado si se solapan con el título"
remove_muted_tags_from_latest: "No mostrar temas etiquetados con etiquetas silenciadas en la lista de temas recientes."
errors:
invalid_email: "Dirección de correo electrónico inválida. "
invalid_username: "No existe ningún usuario con ese nombre de usuario. "
@ -2228,3 +2241,9 @@ es:
activemodel:
errors:
<<: *errors
tags:
staff_tag_disallowed: "La etiqueta \"%{tag}\" sólo puede ser aplicada por los administradores."
staff_tag_remove_disallowed: "La etiqueta \"%{tag}\" sólo puede ser eliminada por los administradores."
rss_by_tag: "Temas con la etiqueta %{tag}"
rss_description:
tag: "Temas etiquetados"

View file

@ -913,6 +913,10 @@ fa_IR:
emoji_set: "می‌خواهید ایموجی شما چطور باشد؟"
enforce_square_emoji: "تحمیل نسبت ابعاد مربع به تمام شکلک ها emojis . "
approve_unless_trust_level: "نوشته ها برای کاربران پایین ت از این سطح اعتماد نیاز به تایید دارد. "
tagging_enabled: "کاربران اجازه انتخاب برچسب برای موضوعات داشته باشند?"
min_trust_to_create_tag: "حداقل سطح اعتماد مورد نیاز برای انتخاب برچسب؟."
max_tags_per_topic: "حداکثر تعداد برچسب انتخابی برای موضوع."
max_tag_length: "حداقل کراکتر یک نوشته برای برچسب انتخابی."
errors:
invalid_email: "آدرس ایمیل نامعتبر"
invalid_username: "هیچ کاربری با این نام کاربری وجود ندارد."
@ -1291,3 +1295,6 @@ fa_IR:
activemodel:
errors:
<<: *errors
rss_by_tag: "موضوعات برچسب خورد %{tag}"
rss_description:
tag: "موضوعات برچسب خورد"

View file

@ -1112,6 +1112,10 @@ fi:
default_categories_watching: "Lista oletuksena tarkkailtavista alueista."
default_categories_tracking: "Lista oletuksena seurattavista alueista."
default_categories_muted: "Lista oletuksena vaimennetuista alueista."
tagging_enabled: "Salli käyttäjien tagata viestejä?"
min_trust_to_create_tag: "Alin luottamustaso tagin luomiseen."
max_tags_per_topic: "Tagien maksimiäärä per aihe."
max_tag_length: "Tagien merkkien maksimimäärä."
errors:
invalid_email: "Sähköpostiosoite ei kelpaa."
invalid_username: "Tällä nimellä ei löydy käyttäjää."
@ -2270,3 +2274,6 @@ fi:
activemodel:
errors:
<<: *errors
rss_by_tag: "Aiheet tagattu %{tag}"
rss_description:
tag: "Tagatut aiheet"

View file

@ -1094,6 +1094,10 @@ fr:
default_categories_watching: "Liste de catégories surveillées par défaut."
default_categories_tracking: "Liste de catégories suivies par défaut."
default_categories_muted: "Liste de catégories silencées par défaut."
tagging_enabled: "Autoriser les utilisateurs à mettre des tags sur les sujets ?"
min_trust_to_create_tag: "Le niveau de confiance requis pour créer un tag."
max_tags_per_topic: "Le nombre maximum de tags qui peuvent être ajouté à un sujet."
max_tag_length: "The nombre maximum de caractères qui peuvent être utilisés pour un tag."
errors:
invalid_email: "Adresse de courriel invalide."
invalid_username: "Il n'y a pas d'utilisateur ayant ce pseudo."
@ -2193,3 +2197,6 @@ fr:
activemodel:
errors:
<<: *errors
rss_by_tag: "Sujets portant le tag %{tag}"
rss_description:
tag: "Sujet tagués"

View file

@ -821,6 +821,10 @@ pl_PL:
approve_unless_trust_level: "Posty użytkowników poniżej tego poziomu zaufania muszą być zatwierdzane"
default_categories_watching: "Lista kategorii obserwowanych domyślnie."
default_categories_tracking: "Lista kategorii śledzonych domyślnie."
tagging_enabled: "Pozwolić użytkownikom na tagowanie tematów?"
min_trust_to_create_tag: "Minimalny poziom zaufania dla tworzenia nowych tagów."
max_tags_per_topic: "Maksymalna ilość tagów przypisanych do tematu."
max_tag_length: "Maksymalna ilość znaków per tag."
errors:
invalid_email: "Nieprawidłowy adres email."
invalid_username: "Użytkownik o takiej nazwie nie istnieje."
@ -1216,3 +1220,6 @@ pl_PL:
activemodel:
errors:
<<: *errors
rss_by_tag: "Tematy otagowane jako %{tag}"
rss_description:
tag: "Otagowane tematy"

View file

@ -1002,6 +1002,19 @@ pt_BR:
emoji_set: "Como você gostaria do seu emoji?"
enforce_square_emoji: "Forçar proporção quadrangular para todos emojis."
approve_unless_trust_level: "Mensagens para os usuários abaixo deste nível de confiança devem ser aprovados"
tagging_enabled: "Permitir que usuários coloquem tags em tópicos?"
min_trust_to_create_tag: "O nível de confiança mínimo necessário para criar uma tag."
max_tags_per_topic: "O número máximo de tags que podem ser aplicados a um tópico."
max_tag_length: "O número máximo de caracteres que pode ser usado em uma tag."
max_tag_search_results: "Quando buscando por tags, o número máximo de resultados a exibir."
show_filter_by_tag: "Exibir um dropdown para filtrar uma lista de tópicos por uma tag."
max_tags_in_filter_list: "Número máximo de tags no filtro do dropdown. As tags mais utilizadas serão exibidas."
tags_sort_alphabetically: "Exibir tags em ordem alfabetica. O padrão é exibí-las em ordem de popularidade."
tag_style: "Estilo visual para as insígnias de tag."
staff_tags: "Uma lista de tags que só podem ser aplicadas por membros da moderação"
min_trust_level_to_tag_topics: "Nível de confiança mínimo necessário para aplicar uma tag"
suppress_overlapping_tags_in_list: "Esconder tags das visualizações de lista, se elas se sobreporem ao título"
remove_muted_tags_from_latest: "Não mostrar tópicos com tags silenciadas na lista de tópicos recentes."
errors:
invalid_email: "Endereço de email inválido"
invalid_username: "Não há nenhum usuário com esse nome de usuário."
@ -1516,3 +1529,9 @@ pt_BR:
activemodel:
errors:
<<: *errors
tags:
staff_tag_disallowed: "A tag \"%{tag}\" só pode ser aplicada pela moderação."
staff_tag_remove_disallowed: "A tag \"%{tag}\" só pode ser removida pela moderação."
rss_by_tag: "Tópicos com a tag %{tag}"
rss_description:
tag: "Tópicos com tag"

View file

@ -984,6 +984,10 @@ tr_TR:
default_categories_watching: "Öntanımlı olarak, izlenen kategorilerin listesi."
default_categories_tracking: "Öntanımlı olarak, takip edilen kategorilerin listesi."
default_categories_muted: "Öntanımlı olarak, sesi kısılan kategorilerin listesi."
tagging_enabled: "Kullanıcılar konularına etiket ekleyebilsinler mi?"
min_trust_to_create_tag: "Etiket oluşturmak için gereken minumum güven seviyesi."
max_tags_per_topic: "Bir konu en fazla kaç adet etiket eklenebilir."
max_tag_length: "Bir etiket en fazla kaç karakterde oluşabilir."
errors:
invalid_email: "Geçersiz e-posta adresi."
invalid_username: "Bu kullanıcı adı ile bir kullanıcı bulunmuyor."
@ -1452,3 +1456,6 @@ tr_TR:
activemodel:
errors:
<<: *errors
rss_by_tag: "Konu %{tag} ile etiketlenmiştir."
rss_description:
tag: "Etiketlenmiş konular"

View file

@ -1079,6 +1079,18 @@ zh_CN:
default_categories_watching: "分类列表默认跟踪。"
default_categories_tracking: "分类列表默认追踪。"
default_categories_muted: "分类列表默认不显示。"
tagging_enabled: "允许用户为主题设置标签?"
min_trust_to_create_tag: "允许创建标签的最小信任等级。"
max_tags_per_topic: "一个主题最多允许有多少个标签。"
max_tag_length: "一个标签允许的最大字符数。"
max_tag_search_results: "当搜索标签时,显示的最多几个结果。"
show_filter_by_tag: "显示一个下拉菜单按照标签过滤主题列表。"
max_tags_in_filter_list: "过滤下拉菜单中显示的最大标签数。最常用的标签将优先显示。"
tags_sort_alphabetically: "按照字母顺序显示标签。默认显示顺序是流行度。"
tag_style: "标签的视觉样式。"
staff_tags: "只可由志愿设置的标签列表"
min_trust_level_to_tag_topics: "给主题加标签的最小信任等级"
suppress_overlapping_tags_in_list: "如果在列表视图中标签覆盖了主题,则隐藏标签"
errors:
invalid_email: "电子邮箱地址无效。"
invalid_username: "没有这个用户名的用户。"
@ -2452,3 +2464,9 @@ zh_CN:
activemodel:
errors:
<<: *errors
tags:
staff_tag_disallowed: "标签\"%{tag}\"只可以由职员标记。"
staff_tag_remove_disallowed: "标签\"%{tag}\"只可以由职员删除。"
rss_by_tag: "%{tag}标签的主题"
rss_description:
tag: "加标签的主题"

View file

@ -606,6 +606,29 @@ Discourse::Application.routes.draw do
get "manifest.json" => "metadata#manifest", as: :manifest
get "opensearch" => "metadata#opensearch", format: :xml
scope "/tags" do
get '/' => 'tags#index'
get '/filter/list' => 'tags#index'
get '/filter/search' => 'tags#search'
get '/check' => 'tags#check_hashtag'
constraints(tag_id: /[^\/]+?/, format: /json|rss/) do
get '/:tag_id.rss' => 'tags#tag_feed'
get '/:tag_id' => 'tags#show', as: 'list_by_tag'
get '/c/:category/:tag_id' => 'tags#show'
get '/c/:parent_category/:category/:tag_id' => 'tags#show'
get '/:tag_id/notifications' => 'tags#notifications'
put '/:tag_id/notifications' => 'tags#update_notifications'
put '/:tag_id' => 'tags#update'
delete '/:tag_id' => 'tags#destroy'
Discourse.filters.each do |filter|
get "/:tag_id/l/#{filter}" => "tags#show_#{filter}"
get "/c/:category/:tag_id/l/#{filter}" => "tags#show_#{filter}"
get "/c/:parent_category/:category/:tag_id/l/#{filter}" => "tags#show_#{filter}"
end
end
end
Discourse.filters.each do |filter|
root to: "list##{filter}", constraints: HomePageConstraint.new("#{filter}"), :as => "list_#{filter}"
end

View file

@ -1157,3 +1157,57 @@ user_preferences:
default_categories_muted:
type: category_list
default: ''
tags:
tagging_enabled:
client: true
default: false
refresh: true
tag_style:
client: true
type: enum
default: 'simple'
choices:
- simple
- bullet
- box
preview: '<div class="discourse-tags"><span class="discourse-tag {{value}}">tag1</span><span class="discourse-tag {{value}}">tag2</span></div>'
max_tags_per_topic:
default: 5
client: true
max_tag_length:
default: 20
client: true
min_trust_to_create_tag:
default: 3
enum: 'TrustLevelSetting'
min_trust_level_to_tag_topics:
default: 0
enum: 'TrustLevelSetting'
client: true
max_tag_search_results:
client: true
default: 5
min: 1
show_filter_by_tag:
client: true
default: false
refresh: true
max_tags_in_filter_list:
default: 30
min: 1
refresh: true
tags_sort_alphabetically:
client: true
default: false
refresh: true
staff_tags:
type: list
client: true
default: ''
suppress_overlapping_tags_in_list:
default: false
client: true
remove_muted_tags_from_latest:
default: false

116
lib/discourse_tagging.rb Normal file
View file

@ -0,0 +1,116 @@
module DiscourseTagging
TAGS_FIELD_NAME = "tags"
TAGS_FILTER_REGEXP = /[<\\\/\>\#\?\&\s]/
# class Engine < ::Rails::Engine
# engine_name "discourse_tagging"
# isolate_namespace DiscourseTagging
# end
def self.clean_tag(tag)
tag.downcase.strip[0...SiteSetting.max_tag_length].gsub(TAGS_FILTER_REGEXP, '')
end
def self.staff_only_tags(tags)
return nil if tags.nil?
staff_tags = SiteSetting.staff_tags.split("|")
tag_diff = tags - staff_tags
tag_diff = tags - tag_diff
tag_diff.present? ? tag_diff : nil
end
def self.tags_for_saving(tags, guardian)
return [] unless guardian.can_tag_topics?
return unless tags
tags.map! {|t| clean_tag(t) }
tags.delete_if {|t| t.blank? }
tags.uniq!
# If the user can't create tags, remove any tags that don't already exist
# TODO: this is doing a full count, it should just check first or use a cache
unless guardian.can_create_tag?
tag_count = TopicCustomField.where(name: TAGS_FIELD_NAME, value: tags).group(:value).count
tags.delete_if {|t| !tag_count.has_key?(t) }
end
return tags[0...SiteSetting.max_tags_per_topic]
end
def self.notification_key(tag_id)
"tags_notification:#{tag_id}"
end
def self.auto_notify_for(tags, topic)
# This insert will run up to SiteSetting.max_tags_per_topic times
tags.each do |tag|
key_name_sql = ActiveRecord::Base.sql_fragment("('#{notification_key(tag)}')", tag)
sql = <<-SQL
INSERT INTO topic_users(user_id, topic_id, notification_level, notifications_reason_id)
SELECT ucf.user_id,
#{topic.id.to_i},
CAST(ucf.value AS INTEGER),
#{TopicUser.notification_reasons[:plugin_changed]}
FROM user_custom_fields AS ucf
WHERE ucf.name IN #{key_name_sql}
AND NOT EXISTS(SELECT 1 FROM topic_users WHERE topic_id = #{topic.id.to_i} AND user_id = ucf.user_id)
AND CAST(ucf.value AS INTEGER) <> #{TopicUser.notification_levels[:regular]}
SQL
ActiveRecord::Base.exec_sql(sql)
end
end
def self.rename_tag(current_user, old_id, new_id)
sql = <<-SQL
UPDATE topic_custom_fields AS tcf
SET value = :new_id
WHERE value = :old_id
AND name = :tags_field_name
AND NOT EXISTS(SELECT 1
FROM topic_custom_fields
WHERE value = :new_id AND name = :tags_field_name AND topic_id = tcf.topic_id)
SQL
user_sql = <<-SQL
UPDATE user_custom_fields
SET name = :new_user_tag_id
WHERE name = :old_user_tag_id
AND NOT EXISTS(SELECT 1
FROM user_custom_fields
WHERE name = :new_user_tag_id)
SQL
ActiveRecord::Base.transaction do
ActiveRecord::Base.exec_sql(sql, new_id: new_id, old_id: old_id, tags_field_name: TAGS_FIELD_NAME)
TopicCustomField.delete_all(name: TAGS_FIELD_NAME, value: old_id)
ActiveRecord::Base.exec_sql(user_sql, new_user_tag_id: notification_key(new_id),
old_user_tag_id: notification_key(old_id))
UserCustomField.delete_all(name: notification_key(old_id))
StaffActionLogger.new(current_user).log_custom('renamed_tag', previous_value: old_id, new_value: new_id)
end
end
def self.top_tags(limit_arg=nil)
# TODO: cache
# TODO: need an index for this (name,value)
TopicCustomField.where(name: TAGS_FIELD_NAME)
.group(:value)
.limit(limit_arg || SiteSetting.max_tags_in_filter_list)
.order('COUNT(value) DESC')
.count
.map {|name, count| name}
end
def self.muted_tags(user)
return [] unless user
UserCustomField.where(user_id: user.id, value: TopicUser.notification_levels[:muted]).pluck(:name).map { |x| x[0,17] == "tags_notification" ? x[18..-1] : nil}.compact
end
end

View file

@ -276,6 +276,18 @@ class Guardian
UserExport.where(user_id: @user.id, created_at: (Time.zone.now.beginning_of_day..Time.zone.now.end_of_day)).count == 0
end
def can_create_tag?
user && user.has_trust_level?(SiteSetting.min_trust_to_create_tag.to_i)
end
def can_tag_topics?
user && user.has_trust_level?(SiteSetting.min_trust_level_to_tag_topics.to_i)
end
def can_admin_tags?
is_staff?
end
private
def is_my_own?(obj)

View file

@ -30,6 +30,10 @@ class Plugin::Instance
[].tap { |plugins|
# also follows symlinks - http://stackoverflow.com/q/357754
Dir["#{parent_path}/**/*/**/plugin.rb"].sort.each do |path|
# tagging is included in core, so don't load it
next if path =~ /discourse-tagging/
source = File.read(path)
metadata = Plugin::Metadata.parse(source)
plugins << self.new(metadata, path)

View file

@ -147,6 +147,7 @@ class PostCreator
track_latest_on_category
enqueue_jobs
BadgeGranter.queue_badge_grant(Badge::Trigger::PostRevision, post: @post)
auto_notify_for_tags
trigger_after_events(@post)
@ -437,6 +438,15 @@ class PostCreator
PostJobsEnqueuer.new(@post, @topic, new_topic?, {import_mode: @opts[:import_mode]}).enqueue_jobs
end
def auto_notify_for_tags
tags = DiscourseTagging.tags_for_saving(@opts[:tags], @guardian)
if tags.present?
@topic.custom_fields.update(DiscourseTagging::TAGS_FIELD_NAME => tags)
@topic.save
DiscourseTagging.auto_notify_for(tags, @topic)
end
end
def new_topic?
@opts[:topic_id].blank?
end

View file

@ -72,6 +72,54 @@ class PostRevisor
tc.check_result(tc.topic.change_category_to_id(category_id))
end
track_topic_field(:tags_empty_array) do |tc, val|
if val.present?
unless tc.guardian.is_staff?
old_tags = tc.topic.tags || []
staff_tags = DiscourseTagging.staff_only_tags(old_tags)
if staff_tags.present?
tc.topic.errors[:base] << I18n.t("tags.staff_tag_remove_disallowed", tag: staff_tags.join(" "))
tc.check_result(false)
next
end
end
tc.record_change(DiscourseTagging::TAGS_FIELD_NAME, tc.topic.custom_fields[DiscourseTagging::TAGS_FIELD_NAME], nil)
tc.topic.custom_fields.delete(DiscourseTagging::TAGS_FIELD_NAME)
end
end
track_topic_field(:tags) do |tc, tags|
if tags.present? && tc.guardian.can_tag_topics?
tags = DiscourseTagging.tags_for_saving(tags, tc.guardian)
old_tags = tc.topic.tags || []
new_tags = tags - old_tags
removed_tags = old_tags - tags
unless tc.guardian.is_staff?
staff_tags = DiscourseTagging.staff_only_tags(new_tags)
if staff_tags.present?
tc.topic.errors[:base] << I18n.t("tags.staff_tag_disallowed", tag: staff_tags.join(" "))
tc.check_result(false)
next
end
staff_tags = DiscourseTagging.staff_only_tags(removed_tags)
if staff_tags.present?
tc.topic.errors[:base] << I18n.t("tags.staff_tag_remove_disallowed", tag: staff_tags.join(" "))
tc.check_result(false)
next
end
end
tc.record_change(DiscourseTagging::TAGS_FIELD_NAME, tc.topic.custom_fields[DiscourseTagging::TAGS_FIELD_NAME], tags)
tc.topic.custom_fields.update(DiscourseTagging::TAGS_FIELD_NAME => tags)
DiscourseTagging.auto_notify_for(new_tags, tc.topic) if new_tags.present?
end
end
# AVAILABLE OPTIONS:
# - revised_at: changes the date of the revision
# - force_new_version: bypass ninja-edit window

View file

@ -67,6 +67,23 @@ module PrettyText
}
end
end
def category_tag_hashtag_lookup(text)
tag_postfix = '::tag'
is_tag = text =~ /#{tag_postfix}$/
if !is_tag && category = Category.query_from_hashtag_slug(text)
[category.url_with_id, text]
elsif is_tag && tag = TopicCustomField.find_by(name: TAGS_FIELD_NAME, value: text.gsub!("#{tag_postfix}", ''))
["#{Discourse.base_url}/tags/#{tag.value}", text]
else
nil
end
end
DiscourseEvent.on(:markdown_context) do |context|
context.eval('opts["categoryHashtagLookup"] = function(c){return helpers.category_tag_hashtag_lookup(c);}')
end
end
@mutex = Mutex.new

View file

@ -313,6 +313,17 @@ class Search
end
end
advanced_filter(/tags?:([a-zA-Z0-9,\-_]+)/) do |posts, match|
tags = match.split(",")
posts.where("topics.id IN (
SELECT tc.topic_id
FROM topic_custom_fields tc
WHERE tc.name = '#{DiscourseTagging::TAGS_FIELD_NAME}' AND
tc.value in (?)
)", tags)
end
private

View file

@ -23,6 +23,12 @@ class TopicCreator
# so we fire the validation event after
# this allows us to add errors
valid = topic.valid?
# not sure where this should go
if !@guardian.is_staff? && staff_only = DiscourseTagging.staff_only_tags(@opts[:tags])
topic.errors[:base] << I18n.t("tags.staff_tag_disallowed", tag: staff_only.join(" "))
end
DiscourseEvent.trigger(:after_validate_topic, topic, self)
valid &&= topic.errors.empty?

View file

@ -308,6 +308,7 @@ class TopicQuery
result = default_results(options)
result = remove_muted_topics(result, @user) unless options && options[:state] == "muted".freeze
result = remove_muted_categories(result, @user, exclude: options[:category])
result = remove_muted_tags(result, @user, options)
# plugins can remove topics here:
self.class.results_filter_callbacks.each do |filter_callback|
@ -334,6 +335,7 @@ class TopicQuery
result = TopicQuery.new_filter(default_results(options.reverse_merge(:unordered => true)), @user.user_option.treat_as_new_topic_start_date)
result = remove_muted_topics(result, @user)
result = remove_muted_categories(result, @user, exclude: options[:category])
result = remove_muted_tags(result, @user, options)
self.class.results_filter_callbacks.each do |filter_callback|
result = filter_callback.call(:new, result, @user, options)
@ -562,6 +564,36 @@ class TopicQuery
list
end
def remove_muted_tags(list, user, opts=nil)
if user.nil? || !SiteSetting.tagging_enabled || !SiteSetting.remove_muted_tags_from_latest
list
else
muted_tags = DiscourseTagging.muted_tags(user)
if muted_tags.empty?
list
else
showing_tag = if opts[:filter]
f = opts[:filter].split('/')
f[0] == 'tags' ? f[1] : nil
else
nil
end
if muted_tags.include?(showing_tag)
list # if viewing the topic list for a muted tag, show all the topics
else
arr = muted_tags.map{ |z| "'#{z}'" }.join(',')
list.where("EXISTS (
SELECT 1
FROM topic_custom_fields tcf
WHERE tcf.name = 'tags'
AND tcf.value NOT IN (#{arr})
AND tcf.topic_id = topics.id
) OR NOT EXISTS (select 1 from topic_custom_fields tcf where tcf.name = 'tags' and tcf.topic_id = topics.id)")
end
end
end
end
def new_messages(params)

View file

@ -11,7 +11,7 @@ class TopicsBulkAction
def self.operations
@operations ||= %w(change_category close archive change_notification_level
reset_read dismiss_posts delete unlist archive_messages
move_messages_to_inbox)
move_messages_to_inbox change_tags)
end
def self.register_operation(name, &block)
@ -130,6 +130,23 @@ class TopicsBulkAction
end
end
def change_tags
tags = @operation[:tags]
tags = DiscourseTagging.tags_for_saving(tags, guardian) if tags.present?
topics.each do |t|
if guardian.can_edit?(t)
if tags.present?
t.custom_fields.update(DiscourseTagging::TAGS_FIELD_NAME => tags)
t.save
DiscourseTagging.auto_notify_for(tags, t)
else
t.custom_fields.delete(DiscourseTagging::TAGS_FIELD_NAME)
end
end
end
end
def guardian
@guardian ||= Guardian.new(@user)
end