diff --git a/README.rdoc b/README.rdoc new file mode 100644 index 0000000..e8ca396 --- /dev/null +++ b/README.rdoc @@ -0,0 +1,3 @@ += importer + +Description goes here diff --git a/app/controllers/importer_controller.rb b/app/controllers/importer_controller.rb new file mode 100644 index 0000000..3122bb3 --- /dev/null +++ b/app/controllers/importer_controller.rb @@ -0,0 +1,247 @@ +require 'fastercsv' +require 'tempfile' + +class ImporterController < ApplicationController + unloadable + + before_filter :find_project + + ISSUE_ATTRS = [:id, :subject, :assigned_to, :fixed_version, + :author, :description, :category, :priority, :tracker, :status, + :start_date, :due_date, :done_ratio, :estimated_hours] + + 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 + + session[:importer_tmpfile] = tmpfilename + session[:importer_splitter] = splitter + session[:importer_wrapper] = wrapper + session[:importer_encoding] = encoding + + # display sample + sample_count = 5 + i = 0 + @samples = [] + + FasterCSV.foreach(tmpfile.path, {:headers=>true, :encoding=>encoding, :quote_char=>wrapper, :col_sep=>splitter}) do |row| + @samples[i] = row + + i += 1 + if i >= sample_count + break + end + end # do + + if @samples.size > 0 + @headers = @samples[0].headers + end + + # fields + @attrs = Array.new + ISSUE_ATTRS.each do |attr| + @attrs.push([l_has_string?("field_#{attr}".to_sym) ? l("field_#{attr}".to_sym) : attr.to_s.humanize, attr]) + end + @project.all_issue_custom_fields.each do |cfield| + @attrs.push([cfield.name, cfield.name]) + end + @attrs.sort! + + end + + def result + tmpfilename = session[:importer_tmpfile] + splitter = session[:importer_splitter] + wrapper = session[:importer_wrapper] + encoding = session[:importer_encoding] + + if tmpfilename + tmpfile = $tmpfiles[tmpfilename] + if tmpfile == nil + flash[:error] = "Missing imported file" + return + end + end + + default_tracker = params[:default_tracker] + update_issue = params[:update_issue] + unique_field = 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] + unique_attr = fields_map[unique_field] + + # check params + if update_issue && unique_attr == nil + flash[:error] = "Unique field hasn't match an issue's field" + 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| + + 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 update_issue + # custom field + if !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 + + 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]]) + + 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 + @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 + end + + # init journal + note = row[journal_field] || '' + journal = issue.init_journal(author || User.current, + note || '') + + @update_count += 1 + else + # ignore none exist issues + if ignore_non_exist + @skip_count += 1 + next + end + end + end + end + + # project affect + if project == nil + project = Project.find_by_id(issue.project_id) + end + @affect_projects_issues.has_key?(project.name) ? + @affect_projects_issues[project.name] += 1 : @affect_projects_issues[project.name] = 1 + + # required attributes + issue.status_id = status != nil ? status.id : issue.status_id + issue.priority_id = priority != nil ? priority.id : issue.priority_id + issue.subject = row[attrs_map["subject"]] || issue.subject + + # optional attributes + issue.description = row[attrs_map["description"]] || issue.description + issue.category_id = category != nil ? category.id : issue.category_id + 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.done_ratio = row[attrs_map["done_ratio"]] || issue.done_ratio + issue.estimated_hours = row[attrs_map["estimated_hours"]] || issue.estimated_hours + + # 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 + end + h + end + + if (!issue.save) + # 记录错误 + @failed_count += 1 + @failed_issues[@handle_count + 1] = row + 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 + end + +private + + def find_project + @project = Project.find(params[:project_id]) + end + +end diff --git a/app/helpers/importer_helper.rb b/app/helpers/importer_helper.rb new file mode 100644 index 0000000..09ebf7b --- /dev/null +++ b/app/helpers/importer_helper.rb @@ -0,0 +1,2 @@ +module ImporterHelper +end diff --git a/app/views/importer/index.html.erb b/app/views/importer/index.html.erb new file mode 100644 index 0000000..9261c6f --- /dev/null +++ b/app/views/importer/index.html.erb @@ -0,0 +1,23 @@ +

<%=l(:label_issue_importer)%>

+ +<% form_tag({:action => 'match'}, {:multipart => true}) do %> + <%= hidden_field_tag 'project_id', @project.id %> + +


+ <%= file_field_tag 'file', :size => 60%>

+ +
<%= l(:label_upload_format) %> +

+ <%= select_tag "encoding", "" %>

+ +

+ <%= text_field_tag "splitter", ',', {:size => 3, :maxlength => 1}%>

+ +

+ <%= text_field_tag "wrapper", '"', {:size => 3, :maxlength => 1}%>

+ +
+ + <%= submit_tag l(:button_upload) %> +<% end %> + diff --git a/app/views/importer/match.html.erb b/app/views/importer/match.html.erb new file mode 100644 index 0000000..e9de6e6 --- /dev/null +++ b/app/views/importer/match.html.erb @@ -0,0 +1,76 @@ +<% content_for :header_tags do %> + <%= stylesheet_link_tag 'importer', :plugin => 'redmine_importer' %> +<% end %> + +

<%= l(:label_match_columns) %>

+ +<% form_tag({:action => 'result'}, {:multipart => true}) do %> + <%= hidden_field_tag 'project_id', @project.id %> +
<%= l(:label_match_select) %> + <% @headers.each do |column| %> + + <% end %> +
+ +
<%= l(:label_import_rule) %> +
+ +
+ <%= observe_field("update_issue", :function => < + +     
+ +     
+ +     
+ +     
+ +
+ + <%= submit_tag l(:button_submit) %> +
+<% end %> + +
+ +<%= l(:label_toplines, @original_filename) %> + + + <% @headers.each do |column| %> + + <% end %> + + + <% @samples.each do |issue| -%> + "> + <% issue.each do |column| %><%= content_tag 'td', column[1] %><% end %> + + <% end %> + "> + <% @headers.each do |column| %><% end %> + + +
<%= column %>
...
\ No newline at end of file diff --git a/app/views/importer/result.html.erb b/app/views/importer/result.html.erb new file mode 100644 index 0000000..8e2c211 --- /dev/null +++ b/app/views/importer/result.html.erb @@ -0,0 +1,31 @@ +<% content_for :header_tags do %> + <%= stylesheet_link_tag 'importer', :plugin => 'redmine_importer' %> +<% end %> + +

<%= l(:label_import_result) %>

+

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

+

<%= l(:label_result_projects) %>
+<% @affect_projects_issues.each do |project, count|%> +
+<% end %>

+
+ +<% if @failed_count > 0 %> +<%= l(:label_result_failed, @failed_count) %> + + + + <% @headers.each do |column| %> + + <% end %> + + + <% @failed_issues.each do |id, issue| -%> + "> + + <% issue.each do |column| %><%= content_tag 'td', column[1] %><% end %> + + <% end %> + +
#<%= column %>
<%= id %>
+<% end %> diff --git a/assets/stylesheets/importer.css b/assets/stylesheets/importer.css new file mode 100644 index 0000000..92bb6df --- /dev/null +++ b/assets/stylesheets/importer.css @@ -0,0 +1,11 @@ +label.tabular{ +text-align: right; +width: 270px; +display: inline-block; +} + +label.tabular2{ +text-align: right; +width: 100px; +display: inline-block; +} diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..6e20dd7 --- /dev/null +++ b/init.rb @@ -0,0 +1,13 @@ +require 'redmine' + +Redmine::Plugin.register :redmine_importer do + name 'Issue Importer' + author 'Martin Liu' + description 'Issue import plugin for Redmine.' + version '0.3.1' + + project_module :importer do + permission :import, :importer => :index + end + menu :project_menu, :importer, { :controller => 'importer', :action => 'index' }, :caption => :label_import, :before => :settings, :param => :project_id +end diff --git a/lang/en.yml b/lang/en.yml new file mode 100644 index 0000000..a560fdc --- /dev/null +++ b/lang/en.yml @@ -0,0 +1,33 @@ + +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_format: "File format settings" +label_upload_encoding: "Encoding:" +label_upload_splitter: "Field seperate char:" +label_upload_wrapper: "Field wrap char:" + +label_load_rules: "Load saved rules" +label_toplines: "Refer to top lines of %s:" +label_match_columns: "Matching Columns" +label_match_select: "Select match field" +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_rule_name: "Input rule name" + +label_import_result: "Import Result" +label_result_notice: "%d issues processed. %d issues success imported." +label_result_projects: "Affected projects:" +label_result_issues: "%d issues" +label_result_failed: "Failed %d rows:" + +option_ignore: "Ignore" + +button_upload: "Upload File" +button_submit: "Submit" +button_save_rules_and_submit: "Save match rules and submit" diff --git a/lang/zh.yml b/lang/zh.yml new file mode 100644 index 0000000..dcd6ef3 --- /dev/null +++ b/lang/zh.yml @@ -0,0 +1,33 @@ + +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: "存储匹配规则后提交" diff --git a/test/functional/importer_controller_test.rb b/test/functional/importer_controller_test.rb new file mode 100644 index 0000000..9d33b25 --- /dev/null +++ b/test/functional/importer_controller_test.rb @@ -0,0 +1,8 @@ +require File.dirname(__FILE__) + '/../test_helper' + +class ImporterControllerTest < ActionController::TestCase + # Replace this with your real tests. + def test_truth + assert true + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..bd1ed0c --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,5 @@ +# Load the normal Rails helper +require File.expand_path(File.dirname(__FILE__) + '/../../../../test/test_helper') + +# Ensure that we are using the temporary fixture path +Engines::Testing.set_fixture_path