FEATURE: Upload tags from CSV (#6484)
This commit is contained in:
parent
8fa59f0548
commit
7ac08f936e
|
@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
<label class="btn {{if addDisabled 'disabled'}}">
|
||||||
|
{{d-icon "upload"}}
|
||||||
|
{{i18n 'admin.watched_words.form.upload'}}
|
||||||
|
<input disabled={{addDisabled}} type="file" accept="text/plain,text/csv" style="visibility: hidden; position: absolute;" />
|
||||||
|
</label>
|
||||||
|
<span class="instructions">{{i18n 'tagging.upload_instructions'}}</span>
|
|
@ -16,6 +16,12 @@ export default DropdownSelectBoxComponent.extend({
|
||||||
name: I18n.t("tagging.manage_groups"),
|
name: I18n.t("tagging.manage_groups"),
|
||||||
description: I18n.t("tagging.manage_groups_description"),
|
description: I18n.t("tagging.manage_groups_description"),
|
||||||
icon: "wrench"
|
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: {
|
actionNames: {
|
||||||
manageGroups: "showTagGroups"
|
manageGroups: "showTagGroups",
|
||||||
|
uploadTags: "showUploader"
|
||||||
},
|
},
|
||||||
|
|
||||||
mutateValue(id) {
|
mutateValue(id) {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import computed from "ember-addons/ember-computed-decorators";
|
import computed from "ember-addons/ember-computed-decorators";
|
||||||
|
import showModal from "discourse/lib/show-modal";
|
||||||
|
|
||||||
export default Ember.Controller.extend({
|
export default Ember.Controller.extend({
|
||||||
sortProperties: ["totalCount:desc", "id"],
|
sortProperties: ["totalCount:desc", "id"],
|
||||||
|
@ -33,6 +34,10 @@ export default Ember.Controller.extend({
|
||||||
sortedByCount: false,
|
sortedByCount: false,
|
||||||
sortedByName: true
|
sortedByName: true
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showUploader() {
|
||||||
|
showModal("tag-upload");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -41,6 +41,10 @@ export default Discourse.Route.extend({
|
||||||
showTagGroups() {
|
showTagGroups() {
|
||||||
this.transitionTo("tagGroups");
|
this.transitionTo("tagGroups");
|
||||||
return true;
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
{{#d-modal-body title='tagging.upload'}}
|
||||||
|
{{tags-uploader closeModal=(action "closeModal") refresh=(route-action "refresh")}}
|
||||||
|
{{/d-modal-body}}
|
|
@ -117,6 +117,35 @@ class TagsController < ::ApplicationController
|
||||||
end
|
end
|
||||||
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
|
def destroy
|
||||||
guardian.ensure_can_admin_tags!
|
guardian.ensure_can_admin_tags!
|
||||||
tag_name = params[:tag_id]
|
tag_name = params[:tag_id]
|
||||||
|
|
|
@ -2700,7 +2700,10 @@ en:
|
||||||
sort_by_name: "name"
|
sort_by_name: "name"
|
||||||
manage_groups: "Manage Tag Groups"
|
manage_groups: "Manage Tag Groups"
|
||||||
manage_groups_description: "Define groups to organize tags"
|
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:
|
filters:
|
||||||
without_category: "%{filter} %{tag} topics"
|
without_category: "%{filter} %{tag} topics"
|
||||||
with_category: "%{filter} %{tag} topics in %{category}"
|
with_category: "%{filter} %{tag} topics in %{category}"
|
||||||
|
|
|
@ -3903,6 +3903,7 @@ en:
|
||||||
staff_tag_disallowed: "The tag \"%{tag}\" may only be applied by staff."
|
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."
|
staff_tag_remove_disallowed: "The tag \"%{tag}\" may only be removed by staff."
|
||||||
minimum_required_tags: "You must select at least %{count} tags."
|
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}"
|
rss_by_tag: "Topics tagged %{tag}"
|
||||||
|
|
||||||
finish_installation:
|
finish_installation:
|
||||||
|
|
|
@ -775,6 +775,7 @@ Discourse::Application.routes.draw do
|
||||||
get '/filter/search' => 'tags#search'
|
get '/filter/search' => 'tags#search'
|
||||||
get '/check' => 'tags#check_hashtag'
|
get '/check' => 'tags#check_hashtag'
|
||||||
get '/personal_messages/:username' => 'tags#personal_messages'
|
get '/personal_messages/:username' => 'tags#personal_messages'
|
||||||
|
post '/upload' => 'tags#upload'
|
||||||
constraints(tag_id: /[^\/]+?/, format: /json|rss/) do
|
constraints(tag_id: /[^\/]+?/, format: /json|rss/) do
|
||||||
get '/:tag_id.rss' => 'tags#tag_feed'
|
get '/:tag_id.rss' => 'tags#tag_feed'
|
||||||
get '/:tag_id' => 'tags#show', as: 'tag_show'
|
get '/:tag_id' => 'tags#show', as: 'tag_show'
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
tag1
|
||||||
|
Capitaltag2
|
||||||
|
spaced tag
|
||||||
|
tag1
|
||||||
|
tag3,taggroup1
|
||||||
|
tag4,taggroup1
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
tag1
|
||||||
|
tag2
|
||||||
|
tag3,taggroup1
|
||||||
|
tag4,taggroup2
|
||||||
|
tag5,with,too,many,columns
|
|
|
@ -369,4 +369,50 @@ describe TagsController do
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
Loading…
Reference in New Issue