diff --git a/app/assets/javascripts/admin/components/tags-uploader.js.es6 b/app/assets/javascripts/admin/components/tags-uploader.js.es6
new file mode 100644
index 00000000000..621045b7316
--- /dev/null
+++ b/app/assets/javascripts/admin/components/tags-uploader.js.es6
@@ -0,0 +1,19 @@
+import UploadMixin from "discourse/mixins/upload";
+
+export default Em.Component.extend(UploadMixin, {
+ type: "csv",
+ uploadUrl: "/tags/upload",
+ addDisabled: Em.computed.alias("uploading"),
+ elementId: "tag-uploader",
+
+ validateUploadedFilesOptions() {
+ return { csvOnly: true };
+ },
+
+ uploadDone() {
+ bootbox.alert(I18n.t("tagging.upload_successful"), () => {
+ this.sendAction("refresh");
+ this.sendAction("closeModal");
+ });
+ }
+});
diff --git a/app/assets/javascripts/admin/templates/components/tags-uploader.hbs b/app/assets/javascripts/admin/templates/components/tags-uploader.hbs
new file mode 100644
index 00000000000..eca270f3695
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/tags-uploader.hbs
@@ -0,0 +1,6 @@
+
+ {{i18n 'tagging.upload_instructions'}}
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 e599f3d9b51..03b5a3c4713 100644
--- a/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6
+++ b/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6
@@ -16,6 +16,12 @@ export default DropdownSelectBoxComponent.extend({
name: I18n.t("tagging.manage_groups"),
description: I18n.t("tagging.manage_groups_description"),
icon: "wrench"
+ },
+ {
+ id: "uploadTags",
+ name: I18n.t("tagging.upload"),
+ description: I18n.t("tagging.upload_description"),
+ icon: "upload"
}
];
@@ -23,7 +29,8 @@ export default DropdownSelectBoxComponent.extend({
},
actionNames: {
- manageGroups: "showTagGroups"
+ manageGroups: "showTagGroups",
+ uploadTags: "showUploader"
},
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 1baa4315dd3..8f4cf5ba29f 100644
--- a/app/assets/javascripts/discourse/controllers/tags-index.js.es6
+++ b/app/assets/javascripts/discourse/controllers/tags-index.js.es6
@@ -1,4 +1,5 @@
import computed from "ember-addons/ember-computed-decorators";
+import showModal from "discourse/lib/show-modal";
export default Ember.Controller.extend({
sortProperties: ["totalCount:desc", "id"],
@@ -33,6 +34,10 @@ export default Ember.Controller.extend({
sortedByCount: false,
sortedByName: true
});
+ },
+
+ showUploader() {
+ showModal("tag-upload");
}
}
});
diff --git a/app/assets/javascripts/discourse/routes/tags-index.js.es6 b/app/assets/javascripts/discourse/routes/tags-index.js.es6
index 1ab8c6fb1c0..c9436e876dc 100644
--- a/app/assets/javascripts/discourse/routes/tags-index.js.es6
+++ b/app/assets/javascripts/discourse/routes/tags-index.js.es6
@@ -41,6 +41,10 @@ export default Discourse.Route.extend({
showTagGroups() {
this.transitionTo("tagGroups");
return true;
+ },
+
+ refresh() {
+ this.refresh();
}
}
});
diff --git a/app/assets/javascripts/discourse/templates/modal/tag-upload.hbs b/app/assets/javascripts/discourse/templates/modal/tag-upload.hbs
new file mode 100644
index 00000000000..d79cb8de400
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/tag-upload.hbs
@@ -0,0 +1,3 @@
+{{#d-modal-body title='tagging.upload'}}
+ {{tags-uploader closeModal=(action "closeModal") refresh=(route-action "refresh")}}
+{{/d-modal-body}}
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 8c7c854e7c6..a493d9c25ca 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -117,6 +117,35 @@ class TagsController < ::ApplicationController
end
end
+ def upload
+ guardian.ensure_can_admin_tags!
+
+ file = params[:file] || params[:files].first
+
+ hijack do
+ begin
+ Tag.transaction do
+ CSV.foreach(file.tempfile) do |row|
+ raise Discourse::InvalidParameters.new(I18n.t("tags.upload_row_too_long")) if row.length > 2
+
+ tag_name = DiscourseTagging.clean_tag(row[0])
+ tag_group_name = row[1] || nil
+
+ tag = Tag.find_by_name(tag_name) || Tag.create!(name: tag_name)
+
+ if tag_group_name
+ tag_group = TagGroup.find_by(name: tag_group_name) || TagGroup.create!(name: tag_group_name)
+ tag.tag_groups << tag_group unless tag.tag_groups.include?(tag_group)
+ end
+ end
+ end
+ render json: success_json
+ rescue Discourse::InvalidParameters => e
+ render json: failed_json.merge(errors: [e.message]), status: 422
+ end
+ end
+ end
+
def destroy
guardian.ensure_can_admin_tags!
tag_name = params[:tag_id]
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 806b24ad8cf..a02309cc0e4 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2700,7 +2700,10 @@ en:
sort_by_name: "name"
manage_groups: "Manage Tag Groups"
manage_groups_description: "Define groups to organize tags"
-
+ upload: "Upload Tags"
+ 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"
filters:
without_category: "%{filter} %{tag} topics"
with_category: "%{filter} %{tag} topics in %{category}"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 2606cee026f..adadf4da713 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -3903,6 +3903,7 @@ en:
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."
minimum_required_tags: "You must select at least %{count} tags."
+ upload_row_too_long: "The CSV file should have one tag per line. Optionally the tag can be followed by a comma, then the tag group name."
rss_by_tag: "Topics tagged %{tag}"
finish_installation:
diff --git a/config/routes.rb b/config/routes.rb
index f4769f67a20..a65c0f02973 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -775,6 +775,7 @@ Discourse::Application.routes.draw do
get '/filter/search' => 'tags#search'
get '/check' => 'tags#check_hashtag'
get '/personal_messages/:username' => 'tags#personal_messages'
+ post '/upload' => 'tags#upload'
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/fixtures/csv/tags.csv b/spec/fixtures/csv/tags.csv
new file mode 100644
index 00000000000..9d44199dd88
--- /dev/null
+++ b/spec/fixtures/csv/tags.csv
@@ -0,0 +1,6 @@
+tag1
+Capitaltag2
+spaced tag
+tag1
+tag3,taggroup1
+tag4,taggroup1
diff --git a/spec/fixtures/csv/tags_invalid.csv b/spec/fixtures/csv/tags_invalid.csv
new file mode 100644
index 00000000000..e1bae581997
--- /dev/null
+++ b/spec/fixtures/csv/tags_invalid.csv
@@ -0,0 +1,5 @@
+tag1
+tag2
+tag3,taggroup1
+tag4,taggroup2
+tag5,with,too,many,columns
diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb
index 9be7b71022c..c24712daff7 100644
--- a/spec/requests/tags_controller_spec.rb
+++ b/spec/requests/tags_controller_spec.rb
@@ -369,4 +369,50 @@ describe TagsController do
end
end
end
+
+ context '#upload_csv' do
+ it 'requires you to be logged in' do
+ post "/tags/upload.json"
+ expect(response.status).to eq(403)
+ end
+
+ context 'while logged in' do
+ let(:csv_file) { File.new("#{Rails.root}/spec/fixtures/csv/tags.csv") }
+ let(:invalid_csv_file) { File.new("#{Rails.root}/spec/fixtures/csv/tags_invalid.csv") }
+
+ let(:file) do
+ Rack::Test::UploadedFile.new(File.open(csv_file))
+ end
+
+ let(:invalid_file) do
+ Rack::Test::UploadedFile.new(File.open(invalid_csv_file))
+ end
+
+ let(:filename) { 'tags.csv' }
+
+ it "fails if you can't manage tags" do
+ sign_in(Fabricate(:user))
+ post "/tags/upload.json", params: { file: file, name: filename }
+ expect(response.status).to eq(403)
+ end
+
+ it "allows staff to bulk upload tags" do
+ sign_in(Fabricate(:moderator))
+ post "/tags/upload.json", params: { file: file, name: filename }
+ expect(response.status).to eq(200)
+ expect(Tag.pluck(:name)).to contain_exactly("tag1", "capitaltag2", "spaced-tag", "tag3", "tag4")
+ expect(Tag.find_by_name("tag3").tag_groups.pluck(:name)).to contain_exactly("taggroup1")
+ expect(Tag.find_by_name("tag4").tag_groups.pluck(:name)).to contain_exactly("taggroup1")
+ end
+
+ it "fails gracefully with invalid input" do
+ sign_in(Fabricate(:moderator))
+
+ expect do
+ post "/tags/upload.json", params: { file: invalid_file, name: filename }
+ expect(response.status).to eq(422)
+ end.not_to change { [Tag.count, TagGroup.count] }
+ end
+ end
+ end
end