diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..181d655 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.~lock* diff --git a/README.rdoc b/README.rdoc index ad22fb5..1dd14ba 100644 --- a/README.rdoc +++ b/README.rdoc @@ -1,9 +1,22 @@ = Redmine Issue Importer -This plugin is broken and should not be used. If the use of it is -required, the only option is to run it in a single process, such as -WEBrick. Running this in a multi-process/thread environment will almost -guaranteed run in to errors due to a global variable that was used by -the initial author. +User documentation for this plugin is +at https://github.com/leovitch/redmine_importer/wiki. +This plugin is functional now, including in multiprocess environments. +The plugin has been testes on Redmine 1.1 and Redmine 1.2.0. +The database is used for intermediate storage. +To install: +- Prerequisites: You'll need the fastercsv gem ('gem install fastercsv' as root). Versions 1.4 through 1.5.3 are tested. +- Download the plugin to your vendors/plugins directory. Be sure to maintain the correct folder name, 'redmine_importer'. +- Run 'rake db:migrate_plugins RAILS_ENV=production' +- Restart your redmine as appropriate (e.g., 'ruby script/server -e production') +- Go to the Admin/Projects/../Modules +- Enable "Importer" + +en, de, zh, pt-BR, and ja localizations included. +The other localizations are up to date, but the zh is a little bit behind. +If anyone could update it, it would be appreciated. + +User documentation at https://github.com/leovitch/redmine_importer/wiki. diff --git a/app/controllers/importer_controller.rb b/app/controllers/importer_controller.rb index f8ecb6d..8e92d4d 100644 --- a/app/controllers/importer_controller.rb +++ b/app/controllers/importer_controller.rb @@ -1,6 +1,28 @@ require 'fastercsv' require 'tempfile' +class MultipleIssuesForUniqueValue < Exception +end + +class NoIssueForUniqueValue < Exception +end + +class Journal < ActiveRecord::Base + def empty?(*args) + (details.empty? && notes.blank?) + end +end + +class ActionController::Flash::FlashHash < Hash + def append(key,msg) + if !self.has_key?(key) + self[key] = msg + else + self[key] += "
"+msg + end + end +end + class ImporterController < ApplicationController unloadable @@ -8,46 +30,36 @@ class ImporterController < ApplicationController ISSUE_ATTRS = [:id, :subject, :assigned_to, :fixed_version, :author, :description, :category, :priority, :tracker, :status, - :start_date, :due_date, :done_ratio, :estimated_hours] + :start_date, :due_date, :done_ratio, :estimated_hours, + :parent_issue, :watchers ] def index end def match - # params - file = params[:file] - splitter = params[:splitter] - wrapper = params[:wrapper] - encoding = params[:encoding] - - # save import file - @original_filename = file.original_filename - tmpfile = Tempfile.new("redmine_importer") - if tmpfile - tmpfile.write(file.read) - tmpfile.close - tmpfilename = File.basename(tmpfile.path) - if !$tmpfiles - $tmpfiles = Hash.new - end - $tmpfiles[tmpfilename] = tmpfile - else - flash[:error] = "Cannot save import file." - return - end + # Delete existing iip to ensure there can't be two iips for a user + ImportInProgress.delete_all(["user_id = ?",User.current.id]) + # save import-in-progress data + iip = ImportInProgress.find_or_create_by_user_id(User.current.id) + iip.quote_char = params[:wrapper] + iip.col_sep = params[:splitter] + iip.encoding = params[:encoding] + iip.created = Time.new + iip.csv_data = params[:file].read + iip.save - session[:importer_tmpfile] = tmpfilename - session[:importer_splitter] = splitter - session[:importer_wrapper] = wrapper - session[:importer_encoding] = encoding + # Put the timestamp in the params to detect + # users with two imports in progress + @import_timestamp = iip.created.strftime("%Y-%m-%d %H:%M:%S") + @original_filename = params[:file].original_filename # display sample sample_count = 5 i = 0 @samples = [] - FasterCSV.foreach(tmpfile.path, {:headers=>true, - :encoding=>encoding, :quote_char=>wrapper, :col_sep=>splitter}) do |row| + FasterCSV.new(iip.csv_data, {:headers=>true, + :encoding=>iip.encoding, :quote_char=>iip.quote_char, :col_sep=>iip.col_sep}).each do |row| @samples[i] = row i += 1 @@ -69,122 +81,236 @@ def match @project.all_issue_custom_fields.each do |cfield| @attrs.push([cfield.name, cfield.name]) end + IssueRelation::TYPES.each_pair do |rtype, rinfo| + @attrs.push([l_or_humanize(rinfo[:name]),rtype]) + end @attrs.sort! end + + # Returns the issue object associated with the given value of the given attribute. + # Raises NoIssueForUniqueValue if not found or MultipleIssuesForUniqueValue + def issue_for_unique_attr(unique_attr, attr_value, row_data) + if @issue_by_unique_attr.has_key?(attr_value) + return @issue_by_unique_attr[attr_value] + end + if unique_attr == "id" + issues = [Issue.find_by_id(attr_value)] + else + query = Query.new(:name => "_importer", :project => @project) + query.add_filter("status_id", "*", [1]) + query.add_filter(unique_attr, "=", [attr_value]) + + issues = Issue.find :all, :conditions => query.statement, :limit => 2, :include => [ :assigned_to, :status, :tracker, :project, :priority, :category, :fixed_version ] + end + + if issues.size > 1 + @failed_count += 1 + @failed_issues[@failed_count] = row_data + flash.append(:warning,"Unique field #{unique_attr} with value '#{attr_value}' in issue #{@failed_count} has duplicate record") + raise MultipleIssuesForUniqueValue, "Unique field #{unique_attr} with value '#{attr_value}' has duplicate record" + else + if issues.size == 0 + raise NoIssueForUniqueValue, "No issue with #{unique_attr} of '#{attr_value}' found" + end + issues.first + end + end - def result - tmpfilename = session[:importer_tmpfile] - splitter = session[:importer_splitter] - wrapper = session[:importer_wrapper] - encoding = session[:importer_encoding] + # Returns the id for the given user or raises RecordNotFound + # Implements a cache of users based on login name + def user_for_login!(login) + begin + if !@user_by_login.has_key?(login) + @user_by_login[login] = User.find_by_login!(login) + end + @user_by_login[login] + rescue ActiveRecord::RecordNotFound + @unfound_class = "User" + @unfound_key = login + raise + end + end + def user_id_for_login!(login) + user = user_for_login!(login) + user ? user.id : nil + end - if tmpfilename - tmpfile = $tmpfiles[tmpfilename] - if tmpfile == nil - flash[:error] = "Missing imported file" - return + + # Returns the id for the given version or raises RecordNotFound. + # Implements a cache of version ids based on version name + # If add_versions is true and a valid name is given, + # will create a new version and save it when it doesn't exist yet. + def version_id_for_name!(project,name,add_versions) + if !@version_id_by_name.has_key?(name) + version = Version.find_by_project_id_and_name(project.id, name) + if !version + if name && (name.length > 0) && add_versions + version = project.versions.build(:name=>name) + version.save + else + @unfound_class = "Version" + @unfound_key = name + raise ActiveRecord::RecordNotFound, "No version named #{name}" + end end + @version_id_by_name[name] = version.id + end + @version_id_by_name[name] + end + + def result + @handle_count = 0 + @update_count = 0 + @skip_count = 0 + @failed_count = 0 + @failed_issues = Hash.new + @affect_projects_issues = Hash.new + # This is a cache of previously inserted issues indexed by the value + # the user provided in the unique column + @issue_by_unique_attr = Hash.new + # Cache of user id by login + @user_by_login = Hash.new + # Cache of Version by name + @version_id_by_name = Hash.new + + # Retrieve saved import data + iip = ImportInProgress.find_by_user_id(User.current.id) + if iip == nil + flash[:error] = "No import is currently in progress" + return + end + if iip.created.strftime("%Y-%m-%d %H:%M:%S") != params[:import_timestamp] + flash[:error] = "You seem to have started another import " \ + "since starting this one. " \ + "This import cannot be completed" + return end default_tracker = params[:default_tracker] update_issue = params[:update_issue] - unique_field = params[:unique_field] + unique_field = params[:unique_field].empty? ? nil : params[:unique_field] journal_field = params[:journal_field] update_other_project = params[:update_other_project] ignore_non_exist = params[:ignore_non_exist] fields_map = params[:fields_map] + send_emails = params[:send_emails] + add_categories = params[:add_categories] + add_versions = params[:add_versions] unique_attr = fields_map[unique_field] + unique_attr_checked = false # Used to optimize some work that has to happen inside the loop + + # attrs_map is fields_map's invert + attrs_map = fields_map.invert + # check params - if update_issue && unique_attr == nil - flash[:error] = "Unique field hasn't match an issue's field" + unique_error = nil + if update_issue + unique_error = l(:text_rmi_specify_unique_field_for_update) + elsif attrs_map["parent_issue"] != nil + unique_error = l(:text_rmi_specify_unique_field_for_column,:column => l(:field_parent_issue)) + else + IssueRelation::TYPES.each_key do |rtype| + if attrs_map[rtype] + unique_error = l(:text_rmi_specify_unique_field_for_column,:column => l("label_#{rtype}".to_sym)) + break + end + end + end + if unique_error && unique_attr == nil + flash[:error] = unique_error return end - - @handle_count = 0 - @update_count = 0 - @skip_count = 0 - @failed_count = 0 - @failed_issues = Hash.new - @affect_projects_issues = Hash.new - - # attrs_map is fields_map's invert - attrs_map = fields_map.invert - FasterCSV.foreach(tmpfile.path, {:headers=>true, :encoding=>encoding, :quote_char=>wrapper, :col_sep=>splitter}) do |row| + FasterCSV.new(iip.csv_data, {:headers=>true, :encoding=>iip.encoding, + :quote_char=>iip.quote_char, :col_sep=>iip.col_sep}).each do |row| project = Project.find_by_name(row[attrs_map["project"]]) - tracker = Tracker.find_by_name(row[attrs_map["tracker"]]) - status = IssueStatus.find_by_name(row[attrs_map["status"]]) - author = User.find_by_login(row[attrs_map["author"]]) - priority = Enumeration.find_by_name(row[attrs_map["priority"]]) - category = IssueCategory.find_by_name(row[attrs_map["category"]]) - assigned_to = User.find_by_login(row[attrs_map["assigned_to"]]) - fixed_version = Version.find_by_name(row[attrs_map["fixed_version"]]) - # new issue or find exists one - issue = Issue.new - journal = nil - issue.project_id = project != nil ? project.id : @project.id - issue.tracker_id = tracker != nil ? tracker.id : default_tracker - issue.author_id = author != nil ? author.id : User.current.id + if !project + project = @project + end - if update_issue - # custom field - if !ISSUE_ATTRS.include?(unique_attr.to_sym) + begin + tracker = Tracker.find_by_name(row[attrs_map["tracker"]]) + status = IssueStatus.find_by_name(row[attrs_map["status"]]) + author = attrs_map["author"] ? user_for_login!(row[attrs_map["author"]]) : User.current + priority = Enumeration.find_by_name(row[attrs_map["priority"]]) + category_name = row[attrs_map["category"]] + category = IssueCategory.find_by_project_id_and_name(project.id, category_name) + if (!category) && category_name && category_name.length > 0 && add_categories + category = project.issue_categories.build(:name => category_name) + category.save + end + assigned_to = row[attrs_map["assigned_to"]] != nil ? user_for_login!(row[attrs_map["assigned_to"]]) : nil + fixed_version_name = row[attrs_map["fixed_version"]] + fixed_version_id = fixed_version_name ? version_id_for_name!(project,fixed_version_name,add_versions) : nil + watchers = row[attrs_map["watchers"]] + # new issue or find exists one + issue = Issue.new + journal = nil + issue.project_id = project != nil ? project.id : @project.id + issue.tracker_id = tracker != nil ? tracker.id : default_tracker + issue.author_id = author != nil ? author.id : User.current.id + rescue ActiveRecord::RecordNotFound + @failed_count += 1 + @failed_issues[@failed_count] = row + flash.append(:warning,"When adding issue #{@failed_count} below, the #{@unfound_class} #{@unfound_key} was not found") + next + end + + # translate unique_attr if it's a custom field -- only on the first issue + if !unique_attr_checked + if unique_field && !ISSUE_ATTRS.include?(unique_attr.to_sym) issue.available_custom_fields.each do |cf| if cf.name == unique_attr unique_attr = "cf_#{cf.id}" break end - end + end end - - if unique_attr == "id" - issues = [Issue.find_by_id(row[unique_field])] - else - query = Query.new(:name => "_importer", :project => @project) - query.add_filter("status_id", "*", [1]) - query.add_filter(unique_attr, "=", [row[unique_field]]) + unique_attr_checked = true + end - issues = Issue.find :all, :conditions => query.statement, :limit => 2, :include => [ :assigned_to, :status, :tracker, :project, :priority, :category, :fixed_version ] - end - - if issues.size > 1 - flash[:warning] = "Unique field #{unique_field} has duplicate record" - @failed_count += 1 - @failed_issues[@handle_count + 1] = row - break - else - if issues.size > 0 - # found issue - issue = issues.first - - # ignore other project's issue or not - if issue.project_id != @project.id && !update_other_project + if update_issue + begin + issue = issue_for_unique_attr(unique_attr,row[unique_field],row) + + # ignore other project's issue or not + if issue.project_id != @project.id && !update_other_project + @skip_count += 1 + next + end + + # ignore closed issue except reopen + if issue.status.is_closed? + if status == nil || status.is_closed? @skip_count += 1 - next - end - - # ignore closed issue except reopen - if issue.status.is_closed? - if status == nil || status.is_closed? - @skip_count += 1 - next - end + next end + end + + # init journal + note = row[journal_field] || '' + journal = issue.init_journal(author || User.current, + note || '') - # init journal - note = row[journal_field] || '' - journal = issue.init_journal(author || User.current, - note || '') - - @update_count += 1 + @update_count += 1 + + rescue NoIssueForUniqueValue + if ignore_non_exist + @skip_count += 1 + next else - # ignore none exist issues - if ignore_non_exist - @skip_count += 1 - next - end + @failed_count += 1 + @failed_issues[@failed_count] = row + flash.append(:warning,"Could not update issue #{@failed_count} below, no match for the value #{row[unique_field]} were found") + next end + + rescue MultipleIssuesForUniqueValue + @failed_count += 1 + @failed_issues[@failed_count] = row + flash.append(:warning,"Could not update issue #{@failed_count} below, multiple matches for the value #{row[unique_field]} were found") + next end end @@ -206,35 +332,150 @@ def result issue.start_date = row[attrs_map["start_date"]] || issue.start_date issue.due_date = row[attrs_map["due_date"]] || issue.due_date issue.assigned_to_id = assigned_to != nil ? assigned_to.id : issue.assigned_to_id - issue.fixed_version_id = fixed_version != nil ? fixed_version.id : issue.fixed_version_id + issue.fixed_version_id = fixed_version_id != nil ? fixed_version_id : issue.fixed_version_id issue.done_ratio = row[attrs_map["done_ratio"]] || issue.done_ratio issue.estimated_hours = row[attrs_map["estimated_hours"]] || issue.estimated_hours + # parent issues + begin + parent_value = row[attrs_map["parent_issue"]] + if parent_value && (parent_value.length > 0) + issue.parent_issue_id = issue_for_unique_attr(unique_attr,parent_value,row).id + end + rescue NoIssueForUniqueValue + if ignore_non_exist + @skip_count += 1 + else + @failed_count += 1 + @failed_issues[@failed_count] = row + flash.append(:warning,"When setting the parent for issue #{@failed_count} below, no matches for the value #{parent_value} were found") + next + end + rescue MultipleIssuesForUniqueValue + @failed_count += 1 + @failed_issues[@failed_count] = row + flash.append(:warning,"When setting the parent for issue #{@failed_count} below, multiple matches for the value #{parent_value} were found") + next + end + # custom fields - issue.custom_field_values = issue.available_custom_fields.inject({}) do |h, c| - if value = row[attrs_map[c.name]] - h[c.id] = value + custom_failed_count = 0 + issue.custom_field_values = issue.available_custom_fields.inject({}) do |h, cf| + if value = row[attrs_map[cf.name]] + begin + if cf.field_format == 'user' + value = user_id_for_login!(value).to_s + elsif cf.field_format == 'version' + value = version_id_for_name!(project,value,add_versions).to_s + elsif cf.field_format == 'date' + value = value.to_date.to_s(:db) + end + h[cf.id] = value + rescue + if custom_failed_count == 0 + custom_failed_count += 1 + @failed_count += 1 + @failed_issues[@failed_count] = row + end + flash.append(:warning,"When trying to set custom field #{cf.name} on issue #{@failed_count} below, value #{value} was invalid") + end end h end + next if custom_failed_count > 0 + + # watchers + watcher_failed_count = 0 + if watchers + addable_watcher_users = issue.addable_watcher_users + watchers.split(',').each do |watcher| + begin + watcher_user = user_id_for_login!(watcher) + if issue.watcher_users.include?(watcher_user) + next + end + if addable_watcher_users.include?(watcher_user) + issue.add_watcher(watcher_user) + end + rescue ActiveRecord::RecordNotFound + if watcher_failed_count == 0 + @failed_count += 1 + @failed_issues[@failed_count] = row + end + watcher_failed_count += 1 + flash.append(:warning,"When trying to add watchers on issue #{@failed_count} below, User #{watcher} was not found") + end + end + end + next if watcher_failed_count > 0 if (!issue.save) # 记录错误 @failed_count += 1 - @failed_issues[@handle_count + 1] = row + @failed_issues[@failed_count] = row + flash.append(:warning,"The following data-validation errors occurred on issue #{@failed_count} in the list below") + issue.errors.each do |attr, error_message| + flash.append(:warning,"  "+error_message) + end + else + if unique_field + @issue_by_unique_attr[row[unique_field]] = issue + end + + if send_emails + if update_issue + if Setting.notified_events.include?('issue_updated') && (!issue.current_journal.empty?) + Mailer.deliver_issue_edit(issue.current_journal) + end + else + if Setting.notified_events.include?('issue_added') + Mailer.deliver_issue_add(issue) + end + end + end + + # Issue relations + begin + IssueRelation::TYPES.each_pair do |rtype, rinfo| + if !row[attrs_map[rtype]] + next + end + other_issue = issue_for_unique_attr(unique_attr,row[attrs_map[rtype]],row) + relations = issue.relations.select { |r| (r.other_issue(issue).id == other_issue.id) && (r.relation_type_for(issue) == rtype) } + if relations.length == 0 + relation = IssueRelation.new( :issue_from => issue, :issue_to => other_issue, :relation_type => rtype ) + relation.save + end + end + rescue NoIssueForUniqueValue + if ignore_non_exist + @skip_count += 1 + next + end + rescue MultipleIssuesForUniqueValue + break + end + + if journal + journal + end + + @handle_count += 1 + end - if journal - journal - end - - @handle_count += 1 end # do if @failed_issues.size > 0 @failed_issues = @failed_issues.sort @headers = @failed_issues[0][1].headers end + + # Clean up after ourselves + iip.delete + + # Garbage prevention: clean up iips older than 3 days + ImportInProgress.delete_all(["created < ?",Time.new - 3*24*60*60]) end private diff --git a/app/models/import_in_progress.rb b/app/models/import_in_progress.rb new file mode 100644 index 0000000..33f35ed --- /dev/null +++ b/app/models/import_in_progress.rb @@ -0,0 +1,5 @@ +class ImportInProgress < ActiveRecord::Base + unloadable + belongs_to :user + belongs_to :project +end diff --git a/app/views/importer/match.html.erb b/app/views/importer/match.html.erb index e9de6e6..30318ea 100644 --- a/app/views/importer/match.html.erb +++ b/app/views/importer/match.html.erb @@ -12,10 +12,15 @@ --> <% form_tag({:action => 'result'}, {:multipart => true}) do %> <%= hidden_field_tag 'project_id', @project.id %> + <%= hidden_field_tag 'import_timestamp', @import_timestamp %>
<%= l(:label_match_select) %> <% @headers.each do |column| %> + <% matched = '' + @attrs.each do |k,v| if v.to_s.casecmp(column.to_s.sub(" ") {|sp| "_" }) == 0 or k.to_s.casecmp(column.to_s) == 0 then matched = v end end + %> + <%= select_tag "fields_map[#{column}]", "" + + options_for_select( @attrs, matched ) %> <% end %>
@@ -23,10 +28,16 @@
+
+ +
+
+

<%= observe_field("update_issue", :function => < -     
-     
diff --git a/app/views/importer/result.html.erb b/app/views/importer/result.html.erb index 1b6b45f..1c17ef7 100644 --- a/app/views/importer/result.html.erb +++ b/app/views/importer/result.html.erb @@ -3,7 +3,7 @@ <% end %>

<%= l(:label_import_result) %>

-

<%= l(:label_result_notice, :handle_count => @handle_count, :success_count => (@handle_count - @failed_count)) %>

+

<%= l(:label_result_notice, :handle_count => @handle_count, :success_count => @handle_count) %>

<%= l(:label_result_projects) %>
<% @affect_projects_issues.each do |project, count|%>
diff --git a/config/locales/de.yml b/config/locales/de.yml new file mode 100644 index 0000000..440971f --- /dev/null +++ b/config/locales/de.yml @@ -0,0 +1,39 @@ +de: + label_import: "Import" + label_issue_importer: "Ticket Importer" + label_upload_notice: "Bitte wählen Sie die zu importierende CSV-Datei aus. Die Datei muss eine Überschriftenzeile haben. Die maximale Grösse der Datei ist 4MB." + label_upload_format: "Dateiformat-Einstellungen" + label_upload_encoding: "Encoding:" + label_upload_splitter: "Trennzeichen:" + label_upload_wrapper: "Felder eingschlossen von:" + + label_load_rules: "Lade gespeicherte Regeln" + label_toplines: "Bezug auf die Top-Lines von {{value}}~Z" + label_match_columns: "Zuordnung der Spalten" + label_match_select: "Auswahl passender Felder" + label_import_rule: "Import-Regeln" + label_default_tracker: "Standard Tracker:" + label_importer_send_emails: "Schicke Benachrichtiungs-E-Mails" + label_importer_add_categories: "Kategorien automatisch hinzufügen" + label_importer_add_versions: "Ziel-Versionen automatisch hinzufügen" + label_update_issue: "Aktualisiere vorhandene Tickets" + label_journal_field: "Spalte als Notiz auswählen:" + label_unique_field: "Wähe ID-Spalte aus (wird fü das Updaten von bestehenden Tickets verwendet bzw. für Relationen):" + label_update_other_project: "Erlaube Aktualisierung von Tickets von anderen Projekten" + label_ignore_non_exist: "Ignoriere nicht existierende Tickets" + label_rule_name: "Name der Input-Regel" + + label_import_result: "Import Ergebnis" + label_result_notice: "{{handle_count}} Tickets verarbeitet. {{success_count}} Tickets erfolgreich importiert." + label_result_projects: "Betroffene Projekte:" + label_result_issues: "{{count}} Tickets" + label_result_failed: "{{count}} fehlgeschlagene Reihen:" + + option_ignore: "Ignorieren" + + button_upload: "Datei hochladen" + button_submit: "Abschicken" + button_save_rules_and_submit: "Abschicken und Abgleich-Regel speichern" + + text_rmi_specify_unique_field_for_update: "Ein eindeutiges Feld muss definiert sein, da die Einstellung für das Update von bestehenden Tickets gesetzt ist." + text_rmi_specify_unique_field_for_column: "Ein eindeutiges Feld muss definiert sein, da die Spalte {{column}} auf andere Tasks zeigen muss." diff --git a/config/locales/en.yml b/config/locales/en.yml index fc4128d..9833680 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,23 +1,26 @@ en: label_import: "Import" label_issue_importer: "Issue Importer" - label_upload_notice: "Select a CSV file to import. Import file must have a header row." + label_upload_notice: "Select a CSV file to import. Import file must have a header row. Maximum size 4MB." label_upload_format: "File format settings" label_upload_encoding: "Encoding:" - label_upload_splitter: "Field seperate char:" - label_upload_wrapper: "Field wrap char:" + label_upload_splitter: "Field separator character:" + label_upload_wrapper: "Field quoting character:" label_load_rules: "Load saved rules" label_toplines: "Refer to top lines of {{value}}:" label_match_columns: "Matching Columns" - label_match_select: "Select match field" + label_match_select: "Select field to set from each column:" label_import_rule: "Import rules" label_default_tracker: "Default tracker:" - label_update_issue: "Update exists issue" - label_journal_field: "Select field as journal:" - label_unique_field: "Select unique field for identify issue:" - label_update_other_project: "Allow update issues of other projects" - label_ignore_non_exist: "Ignore none exist issues" + label_importer_send_emails: "Send notification emails" + label_importer_add_categories: "Auto-add categories" + label_importer_add_versions: "Auto-add target versions" + label_update_issue: "Update existing issues" + label_journal_field: "Select column to use as note:" + label_unique_field: "Select unique-valued column (required for updating existing issues or importing relations):" + label_update_other_project: "Allow updating issues from other projects" + label_ignore_non_exist: "Ignore non-existant issues" label_rule_name: "Input rule name" label_import_result: "Import Result" @@ -31,3 +34,7 @@ en: button_upload: "Upload File" button_submit: "Submit" button_save_rules_and_submit: "Save match rules and submit" + + text_rmi_specify_unique_field_for_update: "Unique field must be specified because Update existing issues is on" + text_rmi_specify_unique_field_for_column: "Unique field must be specified because the column {{column}} needs to refer to other tasks" + diff --git a/config/locales/ja.yml b/config/locales/ja.yml new file mode 100644 index 0000000..1158150 --- /dev/null +++ b/config/locales/ja.yml @@ -0,0 +1,40 @@ +ja: + label_import: "インポート" + label_issue_importer: "チケットのインポート" + label_upload_notice: "CSVファイルを選択する。ヘーダー行が要る。4MBのサイズの限定がある。" + label_upload_format: "ファイルフォーマットのオプション" + label_upload_encoding: "エンコーディング:" + label_upload_splitter: "フィールド区切り文字:" + label_upload_wrapper: "引用文字:" + + label_load_rules: "保存されたルールを読み込む" + label_toplines: "{{value}}の上の行を参考:" + label_match_columns: "コラムの対象のフィールド" + label_match_select: "コラム毎の対象のフィールドを選択:" + label_import_rule: "インポートのルール" + label_default_tracker: "トラッカーのディフォルト:" + label_update_issue: "存在があるチケットを更新:" + label_journal_field: "メモとして入るフィールドを選択:" + label_importer_send_emails: "通常にメールを転送" + label_importer_add_categories: "新しいカテゴリなら自動的に追加" + label_importer_add_versions: "新しいターゲットのバージョンなら自動的に追加" + label_unique_field: "一意な値がある欄を選択(存在がある更新または連携をインポートする際に必須):" + label_update_other_project: "他のプロジェクトのチケットでも更新" + label_ignore_non_exist: "チケットの存在がなくても無視" + label_rule_name: "ルール舞を入れる:" + + label_import_result: "インポートの結果" + label_result_notice: "{{handle_count}}行整理された。{{success_count}}行がうまく行った。" + label_result_projects: "関われるプロジェクト:" + label_result_issues: "{{count}}行" + label_result_failed: "{{count}}行が失敗:" + + option_ignore: "無視" + + button_upload: "ファイルをアップ" + button_submit: "確認" + button_save_rules_and_submit: "マッチルールを保存して確認" + + text_rmi_specify_unique_field_for_update: "存在があるチケットの更新を選択されたので一意な値がある欄を選択が必要です。" + text_rmi_specify_unique_field_for_column: "「{{column}}」という連携があるコラムをインポートしているので一意な値がある欄を選択が必要です。" + diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml new file mode 100644 index 0000000..1c32536 --- /dev/null +++ b/config/locales/pt-BR.yml @@ -0,0 +1,36 @@ +pt-BR: + label_import: "Importar" + label_issue_importer: "Importador de Tarefas" + label_upload_notice: "Selecione um arquivo CSV para importar. Arquivo de importação deve ter uma linha de cabeçalho. Tamanho máximo 4MB." + label_upload_format: "Configurações do formato do arquivo" + label_upload_encoding: "Encoding:" + label_upload_splitter: "Caracter de separação entre campos:" + label_upload_wrapper: "Caracter em volta do nome:" + + label_load_rules: "Carregar regras salvas" + label_toplines: "Refere-se as linhas superiores de {{value}}:" + label_match_columns: "Mapeamento de Colunas" + label_match_select: "Selecione o campo para cada coluna:" + label_import_rule: "Regras de Importação" + label_default_tracker: "Tipo de Tarefa padrão:" + label_importer_send_emails: "Enviar emails de notificação" + label_importer_add_categories: "Adicionar categorias automaticamente" + label_importer_add_versions: "Adicionar versões automaticamente" + label_update_issue: "Atualizar tarefas existentes" + label_journal_field: "Selecione coluna para usar como nota:" + label_unique_field: "Selecione coluna de valor único (obrigatório para atualiza tarefas existentes ou importar relacionamentos):" + label_update_other_project: "Permitir atualizar tarefas de outros projetos" + label_ignore_non_exist: "Ignorar tarefas não existentes" + label_rule_name: "Entre com o nome da regra" + + label_import_result: "Resultado da Importação" + label_result_notice: "{{handle_count}} tarefas processadas. {{success_count}} tarefas importadas com sucesso." + label_result_projects: "Projetos afetados:" + label_result_issues: "{{count}} tarefas" + label_result_failed: "{{count}} linhas falharam:" + + option_ignore: "Ignorar" + + button_upload: "Enviar arquivo" + button_submit: "Enviar" + button_save_rules_and_submit: "Salvar regra de mapeamento e enviar" diff --git a/config/locales/zh.yml b/config/locales/zh.yml index dcd6ef3..ee75ae3 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -1,33 +1,39 @@ +zh: + label_import: "导入" + label_issue_importer: "问题列表导入工具" + label_upload_notice: "请选择需要上传的问题列表CSV文件。文件必须具有标题行。" + label_upload_format: "文件格式设置" + label_upload_encoding: "编码:" + label_upload_splitter: "字段分隔符:" + label_upload_wrapper: "字段包裹符:" + + label_load_rules: "载入已保存的匹配规则" + label_toplines: "参考文件 {{value}} 的头几行:" + label_match_columns: "字段配对" + label_match_select: "选择对应的字段" + label_import_rule: "导入规则" + label_default_tracker: "默认跟踪:" + label_importer_send_emails: "发送提醒邮件" + label_importer_add_categories: "自动新增问题类别" + label_importer_add_versions: "自动新增目标版本" + label_update_issue: "更新已存在的问题" + label_journal_field: "选择用于日志的字段:" + label_unique_field: "选择用于标识问题的唯一字段:" + label_update_other_project: "允许更新其他项目的问题" + label_ignore_non_exist: "忽略不存在的问题" + label_rule_name: "输入规则名称" + + label_import_result: "导入结果" + label_result_notice: "处理了{{handle_count}}个问题。{{success_count}}个问题被成功导入。" + label_result_projects: "受影响的项目:" + label_result_issues: "{{count}} 个问题" + label_result_failed: "{{count}} 条失败的行:" + + option_ignore: "忽略" + + button_upload: "上传文件" + button_submit: "提交" + button_save_rules_and_submit: "存储匹配规则后提交" -label_import: "导入" -label_issue_importer: "问题列表导入工具" -label_upload_notice: "请选择需要上传的问题列表CSV文件。文件必须具有标题行。" -label_upload_format: "文件格式设置" -label_upload_encoding: "编码:" -label_upload_splitter: "字段分隔符:" -label_upload_wrapper: "字段包裹符:" - -label_load_rules: "载入已保存的匹配规则" -label_toplines: "参考文件 %s 的头几行:" -label_match_columns: "字段配对" -label_match_select: "选择对应的字段" -label_import_rule: "导入规则" -label_default_tracker: "默认跟踪:" -label_update_issue: "更新已存在的问题" -label_journal_field: "选择用于日志的字段:" -label_unique_field: "选择用于标识问题的唯一字段:" -label_update_other_project: "允许更新其他项目的问题" -label_ignore_non_exist: "忽略不存在的问题" -label_rule_name: "输入规则名称" - -label_import_result: "导入结果" -label_result_notice: "处理了%d个问题。%d个问题被成功导入。" -label_result_projects: "受影响的项目:" -label_result_issues: "%d个问题" -label_result_failed: "%d条失败的行:" - -option_ignore: "忽略" - -button_upload: "上传文件" -button_submit: "提交" -button_save_rules_and_submit: "存储匹配规则后提交" + text_rmi_specify_unique_field_for_update: "更新现有问题时,必须填写必填字段内容!" + text_rmi_specify_unique_field_for_column: "【{{column}}】列必须填写内容,或在导入时,将【{{column}}】列设置为忽略!" diff --git a/db/migrate/001_create_import_in_progresses.rb b/db/migrate/001_create_import_in_progresses.rb new file mode 100644 index 0000000..81fbacc --- /dev/null +++ b/db/migrate/001_create_import_in_progresses.rb @@ -0,0 +1,16 @@ +class CreateImportInProgresses < ActiveRecord::Migration + def self.up + create_table :import_in_progresses do |t| + t.column :user_id, :integer, :null => false + t.string :quote_char, :limit => 8 + t.string :col_sep, :limit => 8 + t.string :encoding, :limit => 64 + t.column :created, :datetime + t.column :csv_data, :binary, :limit => 4096*1024 + end + end + + def self.down + drop_table :import_in_progresses + end +end diff --git a/init.rb b/init.rb index 6e20dd7..a4b7ac0 100644 --- a/init.rb +++ b/init.rb @@ -2,9 +2,9 @@ Redmine::Plugin.register :redmine_importer do name 'Issue Importer' - author 'Martin Liu' + author 'Martin Liu / Leo Hourvitz' description 'Issue import plugin for Redmine.' - version '0.3.1' + version '1.0' project_module :importer do permission :import, :importer => :index diff --git a/test/fixtures/import_in_progresses.yml b/test/fixtures/import_in_progresses.yml new file mode 100644 index 0000000..0d53737 --- /dev/null +++ b/test/fixtures/import_in_progresses.yml @@ -0,0 +1,11 @@ +# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html +one: + id: 1 + csv_data: + user: + created: 2010-10-09 20:40:03 +two: + id: 2 + csv_data: + user: + created: 2010-10-09 20:40:03 diff --git a/test/samples/AllStandardFields.csv b/test/samples/AllStandardFields.csv new file mode 100644 index 0000000..7d06909 --- /dev/null +++ b/test/samples/AllStandardFields.csv @@ -0,0 +1,2 @@ +"Subject","Description","Assigned To","Fixed version","Author","Category","Priority","Tracker","Status","Start date","Due date","Done Ratio","Estimated hours","Watchers" +"A full task","A lengthily described set of activities.","admin","The Target Version","admin","Default","High","Bug","In Progress",05/01/11,08/28/11,25,200,"test1,test2" diff --git a/test/samples/AllStandardFields.de.csv b/test/samples/AllStandardFields.de.csv new file mode 100644 index 0000000..39d33cd --- /dev/null +++ b/test/samples/AllStandardFields.de.csv @@ -0,0 +1,2 @@ +"Thema","Beschreibung","Zugewiesen an","Zielversion","Autor","Kategorie","Priorität","Tracker","Status","Beginn","Abgabedatum","% erledigt","Geschätzter Aufwand","Watchers" +"A full task","A lengthily described set of activities.","admin","The Target Version","admin","Default","High","Bug","In Progress",05/01/11,08/28/11,25,200,"test1,test2" diff --git a/test/samples/CustomField.csv b/test/samples/CustomField.csv new file mode 100644 index 0000000..af87a37 --- /dev/null +++ b/test/samples/CustomField.csv @@ -0,0 +1,3 @@ +"Subject","Description","External id" +"Important Task","A truly critical deed.","5643-4" +"Less Important Task","Something that would be useful.","5644-6" diff --git a/test/samples/CustomFieldUpdate.csv b/test/samples/CustomFieldUpdate.csv new file mode 100644 index 0000000..52c4f71 --- /dev/null +++ b/test/samples/CustomFieldUpdate.csv @@ -0,0 +1,3 @@ +"Subject","Description","External id" +"Important Task","A truly critical deed.","5643-4" +"Less Important Task","Altering this task to make it even more useful.","5644-6" diff --git a/test/samples/ErroneousStandardFields.csv b/test/samples/ErroneousStandardFields.csv new file mode 100644 index 0000000..bae34c5 --- /dev/null +++ b/test/samples/ErroneousStandardFields.csv @@ -0,0 +1,6 @@ +"Subject","Description","Assigned To","Fixed version","Author","Category","Priority","Tracker","Status","Start date","Due date","Done Ratio","Estimated hours","Watchers","Parent task" +"a task with bad assigned-to","A lengthily described set of activities.","nobody","The Target Version","admin","Default","High","Bug","In Progress",05/01/11,08/28/11,25,200,"test1,test2","" +"A task with bad target version","A lengthily described set of activities.","admin","No Version We Know Of","admin","Default","High","Bug","In Progress",05/01/11,08/28/11,25,200,"test1,test2","" +"A task with bad Author","A lengthily described set of activities.","admin","The Target Version","Nobody","Default","High","Bug","In Progress",05/01/11,08/28/11,25,200,"test1,test2","" +"A task with bad watchers","A lengthily described set of activities.","admin","The Target Version","admin","Default","High","Bug","In Progress",05/01/11,08/28/11,25,200,"testy1,zest2","" +"A task with bad parent","A lengthily described set of activities.","admin","The Target Version","admin","Default","High","Bug","In Progress",05/01/11,08/28/11,25,200,"","Superkalifragilisticexpialidocious" diff --git a/test/samples/IssueRelationsCustomField.csv b/test/samples/IssueRelationsCustomField.csv new file mode 100644 index 0000000..172b264 --- /dev/null +++ b/test/samples/IssueRelationsCustomField.csv @@ -0,0 +1,10 @@ +"Subject","Description","External id","Parent issue","follows" +"Run the company","What we actually have to do","T55",, +"Major new project","The top-level new task","T56","T55", +"Market Research","Find out what people what to buy","T57","T56", +"Research and Development","Design that thing","T58","T56","T57" +"Manufacturing Engineering","Figure out how to make that thing","T59","T56","T58" +"Manufacturing","Make that thing","T60","T56","T59" +"Marketing","Convince people they really want that thing","T61","T56","T58" +"Sales","Sell people that thing","T62","T56","T60" +"Investor Relations","Keep the investors happy in the meantime","T63","T55", diff --git a/test/samples/ParentTaskByCustomField.csv b/test/samples/ParentTaskByCustomField.csv new file mode 100644 index 0000000..07356ae --- /dev/null +++ b/test/samples/ParentTaskByCustomField.csv @@ -0,0 +1,4 @@ +"Subject","Description","External id","Parent issue" +"The overall task","A truly critical deed.","7643-4", +"The first sub-task","Something that would be useful.","7644-6","7643-4" +"The second sub-task","A really important component","7644-7","7643-4" diff --git a/test/samples/ParentTaskBySubject.csv b/test/samples/ParentTaskBySubject.csv new file mode 100644 index 0000000..dda90da --- /dev/null +++ b/test/samples/ParentTaskBySubject.csv @@ -0,0 +1,3 @@ +"Subject","Description","Parent issue" +"Task #1","This is a parent task.", +"Task #1-1","This is a child task.","Task #1" diff --git a/test/samples/TypedCustomFields.csv b/test/samples/TypedCustomFields.csv new file mode 100644 index 0000000..be512c5 --- /dev/null +++ b/test/samples/TypedCustomFields.csv @@ -0,0 +1,3 @@ +"Subject","Description","UserField","DateField","IntegerField","FloatField","BooleanField","VersionField" +"A task of many fields","A task that needs many kinds of custom fields.","test1",2013/12/06 00:00:00,5,3.14,1,"The Target Version" +"Another task of many fields","A task that needs many kinds of custom fields.","test2",2015/06/01 00:00:00,10,4,0,"Another Version" diff --git a/test/samples/TypedErroneousCustomFields.csv b/test/samples/TypedErroneousCustomFields.csv new file mode 100644 index 0000000..448c17a --- /dev/null +++ b/test/samples/TypedErroneousCustomFields.csv @@ -0,0 +1,3 @@ +"Subject","Description","UserField","DateField","IntegerField","FloatField","BooleanField","VersionField" +"A task of many fields","A task that needs many kinds of custom fields.","test1",12/06 00:00:00,5,3.14,1,"The Target Version" +"Another task of many fields","A task that needs many kinds of custom fields.","test2",2015/06/01 00:00:00,1y0,4,0,"Another Version" diff --git a/test/unit/import_in_progress_test.rb b/test/unit/import_in_progress_test.rb new file mode 100644 index 0000000..613ef6a --- /dev/null +++ b/test/unit/import_in_progress_test.rb @@ -0,0 +1,10 @@ +require File.dirname(__FILE__) + '/../test_helper' + +class ImportInProgressTest < ActiveSupport::TestCase + fixtures :import_in_progresses + + # Replace this with your real tests. + def test_truth + assert true + end +end