diff --git a/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 b/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 index 03b5a3c4713..3fdbdf563c7 100644 --- a/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 +++ b/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 @@ -22,6 +22,12 @@ export default DropdownSelectBoxComponent.extend({ name: I18n.t("tagging.upload"), description: I18n.t("tagging.upload_description"), icon: "upload" + }, + { + id: "deleteUnusedTags", + name: I18n.t("tagging.delete_unused"), + description: I18n.t("tagging.delete_unused_description"), + icon: "trash" } ]; @@ -30,7 +36,8 @@ export default DropdownSelectBoxComponent.extend({ actionNames: { manageGroups: "showTagGroups", - uploadTags: "showUploader" + uploadTags: "showUploader", + deleteUnusedTags: "deleteUnused" }, mutateValue(id) { diff --git a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 index 8f4cf5ba29f..71b2c96da23 100644 --- a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 @@ -1,5 +1,7 @@ import computed from "ember-addons/ember-computed-decorators"; import showModal from "discourse/lib/show-modal"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; export default Ember.Controller.extend({ sortProperties: ["totalCount:desc", "id"], @@ -38,6 +40,41 @@ export default Ember.Controller.extend({ showUploader() { showModal("tag-upload"); + }, + + deleteUnused() { + ajax("/tags/unused", { type: "GET" }) + .then(result => { + const displayN = 20; + const tags = result["tags"]; + const tagString = tags.slice(0, displayN).join(", "); + var more = Math.max(0, tags.length - displayN); + const string = + more === 0 + ? I18n.t("tagging.delete_unused_confirmation", { + count: tags.length, + tags: tagString + }) + : I18n.t("tagging.delete_unused_confirmation_more", { + total: tags.length, + tags: tagString, + count: more + }); + + bootbox.confirm( + string, + I18n.t("tagging.cancel_delete_unused"), + I18n.t("tagging.delete_unused"), + proceed => { + if (proceed) { + ajax("/tags/unused", { type: "DELETE" }) + .then(() => this.send("refresh")) + .catch(popupAjaxError); + } + } + ); + }) + .catch(popupAjaxError); } } }); diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 14549413d42..624eff5fb36 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -146,6 +146,19 @@ class TagsController < ::ApplicationController end end + def list_unused + guardian.ensure_can_admin_tags! + render json: { tags: Tag.unused.pluck(:name) } + end + + def destroy_unused + guardian.ensure_can_admin_tags! + tags = Tag.unused + StaffActionLogger.new(current_user).log_custom('deleted_unused_tags', tags: tags.pluck(:name)) + tags.destroy_all + render json: success_json + end + def destroy guardian.ensure_can_admin_tags! tag_name = params[:tag_id] diff --git a/app/models/tag.rb b/app/models/tag.rb index ec42d1a62c5..4e77af16d45 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -9,6 +9,8 @@ class Tag < ActiveRecord::Base where("lower(name) IN (?)", name) end + scope :unused, -> { where(topic_count: 0, pm_topic_count: 0) } + has_many :tag_users # notification settings has_many :topic_tags, dependent: :destroy diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1c557df1956..734f9592a0b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2710,6 +2710,15 @@ en: upload_description: "Upload a text file to create tags in bulk" upload_instructions: "One per line, optionally with a tag group in the format 'tag_name,tag_group'." upload_successful: "Tags uploaded successfully" + delete_unused_confirmation: + one: "1 tag will be deleted: %{tags}" + other: "{{count}} tags will be deleted: %{tags}" + delete_unused_confirmation_more: + one: "{{total}} tags will be deleted: %{tags} and one more" + other: "{{total}} tags will be deleted: %{tags} and %{count} more" + delete_unused: "Delete Unused Tags" + delete_unused_description: "Delete all tags which are not attached to any topics or personal messages" + cancel_delete_unused: "Cancel" filters: without_category: "%{filter} %{tag} topics" with_category: "%{filter} %{tag} topics in %{category}" @@ -3534,6 +3543,7 @@ en: revoke_moderation: "revoke moderation" backup_create: "create backup" deleted_tag: "deleted tag" + deleted_unused_tags: "deleted unused tags" renamed_tag: "renamed tag" revoke_email: "revoke email" lock_trust_level: "lock trust level" diff --git a/config/routes.rb b/config/routes.rb index d3cebd11eed..a9fed0f8914 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -776,6 +776,8 @@ Discourse::Application.routes.draw do get '/check' => 'tags#check_hashtag' get '/personal_messages/:username' => 'tags#personal_messages' post '/upload' => 'tags#upload' + get '/unused' => 'tags#list_unused' + delete '/unused' => 'tags#destroy_unused' constraints(tag_id: /[^\/]+?/, format: /json|rss/) do get '/:tag_id.rss' => 'tags#tag_feed' get '/:tag_id' => 'tags#show', as: 'tag_show' diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 0fcb28e42a9..a48081bf7d6 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -172,4 +172,18 @@ describe Tag do expect(tag.topic_count).to eq(1) end end + + context "unused tags scope" do + let!(:tags) do + [ Fabricate(:tag, name: "used_publically", topic_count: 2, pm_topic_count: 0), + Fabricate(:tag, name: "used_privately", topic_count: 0, pm_topic_count: 3), + Fabricate(:tag, name: "used_everywhere", topic_count: 0, pm_topic_count: 3), + Fabricate(:tag, name: "unused1", topic_count: 0, pm_topic_count: 0), + Fabricate(:tag, name: "unused2", topic_count: 0, pm_topic_count: 0)] + end + + it "returns the correct tags" do + expect(Tag.unused.pluck(:name)).to contain_exactly("unused1", "unused2") + end + end end diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb index 9166a4442a2..740e628dcf0 100644 --- a/spec/requests/tags_controller_spec.rb +++ b/spec/requests/tags_controller_spec.rb @@ -389,6 +389,45 @@ describe TagsController do end end + describe '#unused' do + it "fails if you can't manage tags" do + sign_in(Fabricate(:user)) + get "/tags/unused.json" + expect(response.status).to eq(403) + delete "/tags/unused.json" + expect(response.status).to eq(403) + end + + context 'logged in' do + before do + sign_in(Fabricate(:admin)) + end + + context 'with some tags' do + let!(:tags) { [ + Fabricate(:tag, name: "used_publically", topic_count: 2, pm_topic_count: 0), + Fabricate(:tag, name: "used_privately", topic_count: 0, pm_topic_count: 3), + Fabricate(:tag, name: "used_everywhere", topic_count: 0, pm_topic_count: 3), + Fabricate(:tag, name: "unused1", topic_count: 0, pm_topic_count: 0), + Fabricate(:tag, name: "unused2", topic_count: 0, pm_topic_count: 0) + ]} + + it 'returns the correct unused tags' do + get "/tags/unused.json" + expect(response.status).to eq(200) + json = ::JSON.parse(response.body) + expect(json["tags"]).to contain_exactly("unused1", "unused2") + end + + it 'deletes the correct tags' do + expect { delete "/tags/unused.json" }.to change { Tag.count }.by(-2) & change { UserHistory.count }.by(1) + expect(Tag.pluck(:name)).to contain_exactly("used_publically", "used_privately", "used_everywhere") + end + end + + end + end + context '#upload_csv' do it 'requires you to be logged in' do post "/tags/upload.json"