diff --git a/.gitignore b/.gitignore index 95d44c86f..fcdaca12c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/assets/javascripts/discourse.js b/app/assets/javascripts/discourse.js index f791de454..1aa7fe924 100644 --- a/app/assets/javascripts/discourse.js +++ b/app/assets/javascripts/discourse.js @@ -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; diff --git a/app/assets/javascripts/discourse/adapters/tag-notification.js.es6 b/app/assets/javascripts/discourse/adapters/tag-notification.js.es6 new file mode 100644 index 000000000..7df07c2b3 --- /dev/null +++ b/app/assets/javascripts/discourse/adapters/tag-notification.js.es6 @@ -0,0 +1,7 @@ +import RESTAdapter from 'discourse/adapters/rest'; + +export default RESTAdapter.extend({ + pathFor(store, type, id) { + return "/tags/" + id + "/notifications"; + } +}); diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 816a7e37d..89a45660d 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -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 { diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index 47c6c068d..26521ca4d 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -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); diff --git a/app/assets/javascripts/discourse/components/discourse-tag-bound.js.es6 b/app/assets/javascripts/discourse/components/discourse-tag-bound.js.es6 new file mode 100644 index 000000000..08f014325 --- /dev/null +++ b/app/assets/javascripts/discourse/components/discourse-tag-bound.js.es6 @@ -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'), +}); diff --git a/app/assets/javascripts/discourse/components/tag-chooser.js.es6 b/app/assets/javascripts/discourse/components/tag-chooser.js.es6 new file mode 100644 index 000000000..c942cf599 --- /dev/null +++ b/app/assets/javascripts/discourse/components/tag-chooser.js.es6 @@ -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') + +}); diff --git a/app/assets/javascripts/discourse/components/tag-drop-link.js.es6 b/app/assets/javascripts/discourse/components/tag-drop-link.js.es6 new file mode 100644 index 000000000..8cfad3cd2 --- /dev/null +++ b/app/assets/javascripts/discourse/components/tag-drop-link.js.es6 @@ -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; + } +}); diff --git a/app/assets/javascripts/discourse/components/tag-drop.js.es6 b/app/assets/javascripts/discourse/components/tag-drop.js.es6 new file mode 100644 index 000000000..23e48091b --- /dev/null +++ b/app/assets/javascripts/discourse/components/tag-drop.js.es6 @@ -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(); + } + +}); diff --git a/app/assets/javascripts/discourse/components/tag-notifications-button.js.es6 b/app/assets/javascripts/discourse/components/tag-notifications-button.js.es6 new file mode 100644 index 000000000..b4588df71 --- /dev/null +++ b/app/assets/javascripts/discourse/components/tag-notifications-button.js.es6 @@ -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); + } +}); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index b94ca3347..b22330abb 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -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; diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index 52d0d1de8..973ff24a4 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -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); diff --git a/app/assets/javascripts/discourse/controllers/rename-tag.js.es6 b/app/assets/javascripts/discourse/controllers/rename-tag.js.es6 new file mode 100644 index 000000000..3c5a7bc29 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/rename-tag.js.es6 @@ -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'); + }); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 new file mode 100644 index 000000000..e2a49fee2 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 @@ -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']); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/tags-show.js.es6 b/app/assets/javascripts/discourse/controllers/tags-show.js.es6 new file mode 100644 index 000000000..34c472922 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/tags-show.js.es6 @@ -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 }); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/topic-bulk-actions.js.es6 b/app/assets/javascripts/discourse/controllers/topic-bulk-actions.js.es6 index 2dd79219e..89a6b0e70 100644 --- a/app/assets/javascripts/discourse/controllers/topic-bulk-actions.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic-bulk-actions.js.es6 @@ -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'); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index da342c73a..3607e1d9f 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -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) { diff --git a/app/assets/javascripts/discourse/helpers/discouse-tag.js.es6 b/app/assets/javascripts/discourse/helpers/discouse-tag.js.es6 new file mode 100644 index 000000000..b6d9d3552 --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/discouse-tag.js.es6 @@ -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)); +}); diff --git a/app/assets/javascripts/discourse/lib/category-tag-search.js.es6 b/app/assets/javascripts/discourse/lib/category-tag-search.js.es6 new file mode 100644 index 000000000..d7895ff47 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/category-tag-search.js.es6 @@ -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); + } +}; diff --git a/app/assets/javascripts/discourse/lib/link-tag-hashtag.js.es6 b/app/assets/javascripts/discourse/lib/link-tag-hashtag.js.es6 new file mode 100644 index 000000000..c37610ff0 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/link-tag-hashtag.js.es6 @@ -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); + }); +} diff --git a/app/assets/javascripts/discourse/lib/render-tag.js.es6 b/app/assets/javascripts/discourse/lib/render-tag.js.es6 new file mode 100644 index 000000000..5b6279854 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/render-tag.js.es6 @@ -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 + ""; + + if (params.count) { + val += " x" + params.count + ""; + } + + 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); + } +} diff --git a/app/assets/javascripts/discourse/lib/tag-hashtags.js.es6 b/app/assets/javascripts/discourse/lib/tag-hashtags.js.es6 new file mode 100644 index 000000000..13eb40654 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/tag-hashtags.js.es6 @@ -0,0 +1 @@ +export const TAG_HASHTAG_POSTFIX = '::tag'; diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 9107fe62f..af4c9c41a 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -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({ diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index e6cd21304..888f09838 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -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'), diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index f8c278db1..adc3436d9 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -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}); + }); + }); } diff --git a/app/assets/javascripts/discourse/routes/tags-index.js.es6 b/app/assets/javascripts/discourse/routes/tags-index.js.es6 new file mode 100644 index 000000000..4becf99d5 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/tags-index.js.es6 @@ -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; + } + } +}); diff --git a/app/assets/javascripts/discourse/routes/tags-show.js.es6 b/app/assets/javascripts/discourse/routes/tags-show.js.es6 new file mode 100644 index 000000000..ea8762745 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/tags-show.js.es6 @@ -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; + } + } +}); diff --git a/app/assets/javascripts/discourse/templates/bulk-tag.hbs b/app/assets/javascripts/discourse/templates/bulk-tag.hbs new file mode 100644 index 000000000..1ffe345e3 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/bulk-tag.hbs @@ -0,0 +1,5 @@ +

{{i18n "topics.bulk.choose_new_tags"}}

+ +

{{tag-chooser tags=tags}}

+ +{{d-button action="changeTags" disabled=emptyTags label="topics.bulk.change_tags"}} diff --git a/app/assets/javascripts/discourse/templates/category-tag-autocomplete.raw.hbs b/app/assets/javascripts/discourse/templates/category-tag-autocomplete.raw.hbs new file mode 100644 index 000000000..886c7bba7 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/category-tag-autocomplete.raw.hbs @@ -0,0 +1,13 @@ +
+ +
diff --git a/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs b/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs index 756b976c3..f737c9697 100644 --- a/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs +++ b/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs @@ -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"}}
diff --git a/app/assets/javascripts/discourse/templates/components/discourse-tag-bound.hbs b/app/assets/javascripts/discourse/templates/components/discourse-tag-bound.hbs new file mode 100644 index 000000000..d7eb83adb --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/discourse-tag-bound.hbs @@ -0,0 +1 @@ +{{tagRecord.id}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/components/tag-drop.hbs b/app/assets/javascripts/discourse/templates/components/tag-drop.hbs new file mode 100644 index 000000000..46e4f6604 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/tag-drop.hbs @@ -0,0 +1,21 @@ +{{#if showTagDropdown}} + {{#if tagId}} + {{tagId}} + {{else}} + {{allTagsLabel}} + {{/if}} + + {{#if tags}} + +
+
{{allTagsLabel}}
+ {{#if renderTags}} + {{#each t in tags}} +
+ {{tag-drop-link tagId=t category=currentCategory}} +
+ {{/each}} + {{/if}} +
+ {{/if}} +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/components/topic-category.hbs b/app/assets/javascripts/discourse/templates/components/topic-category.hbs index 8a914440e..cf70e3e8a 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-category.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-category.hbs @@ -2,5 +2,12 @@ {{bound-category-link topic.category.parentCategory}} {{/if}} {{bound-category-link topic.category hideParent=true}} +{{#if siteSettings.tagging_enabled}} +
+ {{#each t in topic.tags}} + {{discourse-tag t}} + {{/each}} +
+{{/if}} {{plugin-outlet "topic-category"}} diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index 06ebfdfbe..1ecbecab1 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -97,6 +97,9 @@ {{#if currentUser}}
{{plugin-outlet "composer-fields-below"}} + {{#if canEditTags}} + {{tag-chooser tags=model.tags tabIndex="4"}} + {{/if}} {{i18n 'cancel'}} diff --git a/app/assets/javascripts/discourse/templates/full-page-search.hbs b/app/assets/javascripts/discourse/templates/full-page-search.hbs index e216f7ab3..7c39f11aa 100644 --- a/app/assets/javascripts/discourse/templates/full-page-search.hbs +++ b/app/assets/javascripts/discourse/templates/full-page-search.hbs @@ -70,6 +70,9 @@
{{category-link result.topic.category}} + {{#each result.topic.tags as |tag|}} + {{discourse-tag tag}} + {{/each}} {{plugin-outlet "full-page-search-category"}}
diff --git a/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs b/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs index 67b03cfbd..6efca1826 100644 --- a/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/topic-list-item.raw.hbs @@ -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}} +
+ {{#each tag in topic.visibleListTags}} + {{discourse-tag tag}} + {{/each}} +
+ {{/if}} {{plugin-outlet "topic-list-tags"}} {{#if expandPinned}} {{raw "list/topic-excerpt" topic=topic}} diff --git a/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs b/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs index 0c1190703..aff527a69 100644 --- a/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/list/topic_list_item.raw.hbs @@ -27,6 +27,14 @@ {{/unless}} + {{#if context.topic.tags}} +
+ {{#each tag in context.topic.visibleListTags}} + {{discourse-tag tag}} + {{/each}} +
+ {{/if}} + {{plugin-outlet "topic-list-tags"}}
diff --git a/app/assets/javascripts/discourse/templates/modal/history.hbs b/app/assets/javascripts/discourse/templates/modal/history.hbs index 21e166275..72857dbf8 100644 --- a/app/assets/javascripts/discourse/templates/modal/history.hbs +++ b/app/assets/javascripts/discourse/templates/modal/history.hbs @@ -73,6 +73,19 @@
{{/if}} {{/if}} + {{#if model.tags_changes}} +
+ {{i18n "tagging.changed"}} + {{#each previousTagChanges as |t|}} + {{discourse-tag t}} + {{/each}} + → +   + {{#each currentTagChanges as |t|}} + {{discourse-tag t}} + {{/each}} +
+ {{/if}} {{plugin-outlet "post-revisions"}} diff --git a/app/assets/javascripts/discourse/templates/rename-tag.hbs b/app/assets/javascripts/discourse/templates/rename-tag.hbs new file mode 100644 index 000000000..0d69f7f65 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/rename-tag.hbs @@ -0,0 +1,13 @@ + + + diff --git a/app/assets/javascripts/discourse/templates/tags.hbs b/app/assets/javascripts/discourse/templates/tags.hbs new file mode 100644 index 000000000..3fd70a3f7 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/tags.hbs @@ -0,0 +1,9 @@ +
+
+
+
+ {{outlet}} +
+
+
+
diff --git a/app/assets/javascripts/discourse/templates/tags/index.hbs b/app/assets/javascripts/discourse/templates/tags/index.hbs new file mode 100644 index 000000000..e0d86aa19 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/tags/index.hbs @@ -0,0 +1,15 @@ +

{{i18n "tagging.all_tags"}}

+ +
+ {{i18n "tagging.sort_by"}} + {{i18n "tagging.sort_by_count"}} + {{i18n "tagging.sort_by_name"}} +
+ +
+ {{#each tag in sortedTags}} +
+ {{discourse-tag tag.id}} x {{tag.count}} +
+ {{/each}} +
diff --git a/app/assets/javascripts/discourse/templates/tags/show.hbs b/app/assets/javascripts/discourse/templates/tags/show.hbs new file mode 100644 index 000000000..757861c9b --- /dev/null +++ b/app/assets/javascripts/discourse/templates/tags/show.hbs @@ -0,0 +1,58 @@ +
+
+ {{#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}} + + {{/if}} + {{/if}} + + {{#if showTagFilter}} + {{bread-crumbs categories=categories + category=category + tagId=tag.id + noSubcategories=noSubcategories + hideSubcategories=showingSubcategoryList}} + + {{navigation-bar navItems=navItems filterMode=filterMode}} + {{else}} +

+ {{#link-to 'tags'}}{{i18n "tagging.tags"}}{{/link-to}} + {{fa-icon "angle-right"}} + {{discourse-tag-bound tagRecord=tag style="simple"}} +

+ {{/if}} +
+
+ + diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index ab5e84bcb..6772028a3 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -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"}} diff --git a/app/assets/javascripts/discourse/views/rename-tag.js.es6 b/app/assets/javascripts/discourse/views/rename-tag.js.es6 new file mode 100644 index 000000000..68a0b12c5 --- /dev/null +++ b/app/assets/javascripts/discourse/views/rename-tag.js.es6 @@ -0,0 +1,5 @@ +import ModalBodyView from 'discourse/views/modal-body'; + +export default ModalBodyView.extend({ + title: I18n.t("tagging.rename_tag") +}); diff --git a/app/assets/javascripts/discourse/views/tags-show.js.es6 b/app/assets/javascripts/discourse/views/tags-show.js.es6 new file mode 100644 index 000000000..384e1a857 --- /dev/null +++ b/app/assets/javascripts/discourse/views/tags-show.js.es6 @@ -0,0 +1,3 @@ +import DiscoveryTopicsView from "discourse/views/discovery-topics"; + +export default DiscoveryTopicsView; diff --git a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 index ca9a925b0..53b3bc14f 100644 --- a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 @@ -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)); }, diff --git a/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 b/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 index 1b6b8a269..3215bc8f7 100644 --- a/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header-topic-info.js.es6 @@ -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); diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss new file mode 100644 index 000000000..40ee27686 --- /dev/null +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -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; + } +} diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 000000000..0aecc1be9 --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -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 diff --git a/app/helpers/topics_helper.rb b/app/helpers/topics_helper.rb index 8f8dcd98e..a92617e80 100644 --- a/app/helpers/topics_helper.rb +++ b/app/helpers/topics_helper.rb @@ -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 diff --git a/app/models/topic.rb b/app/models/topic.rb index 5ac125e30..556130d79 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -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) diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb index cce332a6d..4abdc36f0 100644 --- a/app/models/topic_list.rb +++ b/app/models/topic_list.rb @@ -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 diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 2a3a49170..d49257b14 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -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 diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb index a9f961827..5a8ac8d37 100644 --- a/app/serializers/topic_list_item_serializer.rb +++ b/app/serializers/topic_list_item_serializer.rb @@ -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 diff --git a/app/serializers/topic_list_serializer.rb b/app/serializers/topic_list_serializer.rb index 619388814..f932b644d 100644 --- a/app/serializers/topic_list_serializer.rb +++ b/app/serializers/topic_list_serializer.rb @@ -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 diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index bacdb2dcf..b30d0c9e6 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -33,7 +33,8 @@ class TopicViewSerializer < ApplicationSerializer :word_count, :deleted_at, :pending_posts_count, - :user_id + :user_id, + :tags attributes :draft, :draft_key, diff --git a/app/views/list/list.erb b/app/views/list/list.erb index bfb7c511c..9ba31d2a8 100644 --- a/app/views/list/list.erb +++ b/app/views/list/list.erb @@ -2,6 +2,14 @@ <%= server_plugin_outlet "topic_list_header" %> +<%- if SiteSetting.tagging_enabled && @tag_id %> +

+ <%= link_to "#{Discourse.base_url}/t/#{@tag_id}", itemprop: 'item' do %> + <%= @tag_id %> + <% end %> +

+<% end %> + <% if @category %>

<% if @category.parent_category %> diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb index 010999cc9..b227aa3c6 100644 --- a/app/views/topics/show.html.erb +++ b/app/views/topics/show.html.erb @@ -17,7 +17,19 @@ <% end %> <% end %> + +<%- if SiteSetting.tagging_enabled && @topic_view.topic.tags.present? %> +
+ <%= t 'js.tagging.tags' %>: + + <%- @topic_view.topic.tags.each do |t| %> + <%= t %> + <%- end %> +
+<% end %> + <%= server_plugin_outlet "topic_header" %> +
<%- if include_crawler_content? %> diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index 6a9f585a8..2bf819fd0 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -1052,6 +1052,9 @@ de: selected: one: "Du hast ein Thema ausgewählt." other: "Du hast {{count}} 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:

+ + 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 {{tag}}" + 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." diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 482b17ee3..506989e43 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1164,6 +1164,9 @@ en: selected: one: "You have selected 1 topic." other: "You have selected {{count}} 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:

+ + 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." + diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index 09aa1e8f1..dedf5e6ff 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -1052,6 +1052,9 @@ es: selected: one: "Has seleccionado 1 tema." other: "Has seleccionado {{count}} 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:

+ + 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." + diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml index 048a86b1e..4706ddeb4 100644 --- a/config/locales/client.fa_IR.yml +++ b/config/locales/client.fa_IR.yml @@ -918,6 +918,9 @@ fa_IR: choose_new_category: "یک دسته بندی جدید برای موضوع انتخاب نمایید" selected: other: "شما تعداد {{count}} موضوع را انتخاب کرده اید." + 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:

+ 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: "به شما هیچ چیزی در باره نوشته های این تگ اطلاع رسانی نخواهد شد." + diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml index ad45574ec..213604886 100644 --- a/config/locales/client.fi.yml +++ b/config/locales/client.fi.yml @@ -1019,6 +1019,9 @@ fi: selected: one: "Olet valinnut yhden ketjun." other: "Olet valinnut {{count}} 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:

+ tagging: + all_tags: "Kaikki tagit" + changed: "muutetut tagit:" + tags: "Tagit" + choose_for_topic: "valitse sopivat tagit aiheelle" + topics_tagged: "Aiheet tagattu {{tag}}" + 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." diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index e46cd3dfb..1483e4038 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -1018,6 +1018,9 @@ fr: selected: one: "Vous avez sélectionné 1 sujet." other: "Vous avez sélectionné {{count}} 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:

+ 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." diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml index 36e38590e..452b8630e 100644 --- a/config/locales/client.nb_NO.yml +++ b/config/locales/client.nb_NO.yml @@ -899,6 +899,9 @@ nb_NO: selected: one: "Du har valgt 1 emne." other: "Du har valgt {{count}} 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." + diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index 51327dcd4..ee67063e4 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -2699,3 +2699,9 @@ pl_PL:

+ tagging: + all_tags: "Wszystkie tagi" + changed: "zmienione tagi:" + tags: "Tagi" + choose_for_topic: "wybierz opcjonalne tagi dla tego tematu" + topics_tagged: "Tematy otagowane jako {{tag}}" diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml index 964b58e17..ed2dba1d6 100644 --- a/config/locales/client.pt.yml +++ b/config/locales/client.pt.yml @@ -1052,6 +1052,9 @@ pt: selected: one: "Selecionou 1 tópico." other: "Selecionou {{count}} 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:

+ 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" diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index 55ddb7114..b6ddc76de 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -1049,6 +1049,9 @@ pt_BR: selected: one: "Você selecionou 1 tópico." other: "Você selecionou {{count}} 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:

+ 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." diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index c80200f50..42180d736 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -1098,6 +1098,9 @@ ru: few: "Вы выбрали {{count}} темы." many: "Вы выбрали {{count}} тем." other: "Вы выбрали {{count}} тем." + 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:

+ 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: "Не уведомлять об изменениях в новых темах с этим тегом и скрыть их из непрочитанных." diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml index 153ab3ef0..85c88db7f 100644 --- a/config/locales/client.tr_TR.yml +++ b/config/locales/client.tr_TR.yml @@ -991,6 +991,9 @@ tr_TR: choose_new_category: "Konular için yeni bir kategori seçin:" selected: other: "{{count}} 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:

+ 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." diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index 66e9c06c6..d5f127fe0 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -1005,6 +1005,9 @@ zh_CN: choose_new_category: "为主题选择新分类:" selected: other: "你已经选择了 {{count}}个主题" + 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:

+ 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: "没有更多搜索结果了。" diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index ba044fc61..baf013524 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -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" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 4b19733f5..d656359e3 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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" diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index 9b9d1c42d..49cb33a84 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -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" diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml index d367f254f..34a3cfcf4 100644 --- a/config/locales/server.fa_IR.yml +++ b/config/locales/server.fa_IR.yml @@ -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: "موضوعات برچسب خورد" diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index 77b404e58..9770a1a23 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -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" diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index 0ed972340..b6900673d 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -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" diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index f8786663a..b91f1c7ff 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -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" diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml index e1f67d2fc..e7882be42 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -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" diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index 0bf20b989..a31e114a6 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -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" diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index a8b27058e..ec818080b 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -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: "加标签的主题" diff --git a/config/routes.rb b/config/routes.rb index 69a57eff7..b1e72d24a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/site_settings.yml b/config/site_settings.yml index ea6fdf532..3a593ce3e 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -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: '
tag1tag2
' + 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 + diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb new file mode 100644 index 000000000..944f8bfeb --- /dev/null +++ b/lib/discourse_tagging.rb @@ -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 diff --git a/lib/guardian.rb b/lib/guardian.rb index 5821e6c03..1a6c9d137 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -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) diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 7fb3735de..cccc8aa67 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -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) diff --git a/lib/post_creator.rb b/lib/post_creator.rb index 9f81977ec..bd0d67295 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -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 diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index 093b55c92..d1fb4bcea 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -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 diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index bc804a161..0c35f70b0 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -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 diff --git a/lib/search.rb b/lib/search.rb index 5bfd9d4e2..224c14c35 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -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 diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index d0548885d..53409a183 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -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? diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 1c061107c..f14c3dca4 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -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) diff --git a/lib/topics_bulk_action.rb b/lib/topics_bulk_action.rb index 541d283a1..36c17f228 100644 --- a/lib/topics_bulk_action.rb +++ b/lib/topics_bulk_action.rb @@ -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