##// END OF EJS Templates
Auto-detect attachment content type when blank (#3782)....
Jean-Philippe Lang -
r3144:ff7eb7b23a6a
parent child
Show More
@@ -1,79 +1,87
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class AttachmentsController < ApplicationController
18 class AttachmentsController < ApplicationController
19 before_filter :find_project
19 before_filter :find_project
20 before_filter :file_readable, :read_authorize, :except => :destroy
20 before_filter :file_readable, :read_authorize, :except => :destroy
21 before_filter :delete_authorize, :only => :destroy
21 before_filter :delete_authorize, :only => :destroy
22
22
23 verify :method => :post, :only => :destroy
23 verify :method => :post, :only => :destroy
24
24
25 def show
25 def show
26 if @attachment.is_diff?
26 if @attachment.is_diff?
27 @diff = File.new(@attachment.diskfile, "rb").read
27 @diff = File.new(@attachment.diskfile, "rb").read
28 render :action => 'diff'
28 render :action => 'diff'
29 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
29 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
30 @content = File.new(@attachment.diskfile, "rb").read
30 @content = File.new(@attachment.diskfile, "rb").read
31 render :action => 'file'
31 render :action => 'file'
32 else
32 else
33 download
33 download
34 end
34 end
35 end
35 end
36
36
37 def download
37 def download
38 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
38 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
39 @attachment.increment_download
39 @attachment.increment_download
40 end
40 end
41
41
42 # images are sent inline
42 # images are sent inline
43 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
43 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
44 :type => @attachment.content_type,
44 :type => detect_content_type(@attachment),
45 :disposition => (@attachment.image? ? 'inline' : 'attachment')
45 :disposition => (@attachment.image? ? 'inline' : 'attachment')
46
46
47 end
47 end
48
48
49 def destroy
49 def destroy
50 # Make sure association callbacks are called
50 # Make sure association callbacks are called
51 @attachment.container.attachments.delete(@attachment)
51 @attachment.container.attachments.delete(@attachment)
52 redirect_to :back
52 redirect_to :back
53 rescue ::ActionController::RedirectBackError
53 rescue ::ActionController::RedirectBackError
54 redirect_to :controller => 'projects', :action => 'show', :id => @project
54 redirect_to :controller => 'projects', :action => 'show', :id => @project
55 end
55 end
56
56
57 private
57 private
58 def find_project
58 def find_project
59 @attachment = Attachment.find(params[:id])
59 @attachment = Attachment.find(params[:id])
60 # Show 404 if the filename in the url is wrong
60 # Show 404 if the filename in the url is wrong
61 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
61 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
62 @project = @attachment.project
62 @project = @attachment.project
63 rescue ActiveRecord::RecordNotFound
63 rescue ActiveRecord::RecordNotFound
64 render_404
64 render_404
65 end
65 end
66
66
67 # Checks that the file exists and is readable
67 # Checks that the file exists and is readable
68 def file_readable
68 def file_readable
69 @attachment.readable? ? true : render_404
69 @attachment.readable? ? true : render_404
70 end
70 end
71
71
72 def read_authorize
72 def read_authorize
73 @attachment.visible? ? true : deny_access
73 @attachment.visible? ? true : deny_access
74 end
74 end
75
75
76 def delete_authorize
76 def delete_authorize
77 @attachment.deletable? ? true : deny_access
77 @attachment.deletable? ? true : deny_access
78 end
78 end
79
80 def detect_content_type(attachment)
81 content_type = attachment.content_type
82 if content_type.blank?
83 content_type = Redmine::MimeType.of(attachment.filename)
84 end
85 content_type
86 end
79 end
87 end
@@ -1,157 +1,160
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/md5"
18 require "digest/md5"
19
19
20 class Attachment < ActiveRecord::Base
20 class Attachment < ActiveRecord::Base
21 belongs_to :container, :polymorphic => true
21 belongs_to :container, :polymorphic => true
22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23
23
24 validates_presence_of :container, :filename, :author
24 validates_presence_of :container, :filename, :author
25 validates_length_of :filename, :maximum => 255
25 validates_length_of :filename, :maximum => 255
26 validates_length_of :disk_filename, :maximum => 255
26 validates_length_of :disk_filename, :maximum => 255
27
27
28 acts_as_event :title => :filename,
28 acts_as_event :title => :filename,
29 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
29 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
30
30
31 acts_as_activity_provider :type => 'files',
31 acts_as_activity_provider :type => 'files',
32 :permission => :view_files,
32 :permission => :view_files,
33 :author_key => :author_id,
33 :author_key => :author_id,
34 :find_options => {:select => "#{Attachment.table_name}.*",
34 :find_options => {:select => "#{Attachment.table_name}.*",
35 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
35 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
36 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
36 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
37
37
38 acts_as_activity_provider :type => 'documents',
38 acts_as_activity_provider :type => 'documents',
39 :permission => :view_documents,
39 :permission => :view_documents,
40 :author_key => :author_id,
40 :author_key => :author_id,
41 :find_options => {:select => "#{Attachment.table_name}.*",
41 :find_options => {:select => "#{Attachment.table_name}.*",
42 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
42 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
43 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
43 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
44
44
45 cattr_accessor :storage_path
45 cattr_accessor :storage_path
46 @@storage_path = "#{RAILS_ROOT}/files"
46 @@storage_path = "#{RAILS_ROOT}/files"
47
47
48 def validate
48 def validate
49 if self.filesize > Setting.attachment_max_size.to_i.kilobytes
49 if self.filesize > Setting.attachment_max_size.to_i.kilobytes
50 errors.add(:base, :too_long, :count => Setting.attachment_max_size.to_i.kilobytes)
50 errors.add(:base, :too_long, :count => Setting.attachment_max_size.to_i.kilobytes)
51 end
51 end
52 end
52 end
53
53
54 def file=(incoming_file)
54 def file=(incoming_file)
55 unless incoming_file.nil?
55 unless incoming_file.nil?
56 @temp_file = incoming_file
56 @temp_file = incoming_file
57 if @temp_file.size > 0
57 if @temp_file.size > 0
58 self.filename = sanitize_filename(@temp_file.original_filename)
58 self.filename = sanitize_filename(@temp_file.original_filename)
59 self.disk_filename = Attachment.disk_filename(filename)
59 self.disk_filename = Attachment.disk_filename(filename)
60 self.content_type = @temp_file.content_type.to_s.chomp
60 self.content_type = @temp_file.content_type.to_s.chomp
61 if content_type.blank?
62 self.content_type = Redmine::MimeType.of(filename)
63 end
61 self.filesize = @temp_file.size
64 self.filesize = @temp_file.size
62 end
65 end
63 end
66 end
64 end
67 end
65
68
66 def file
69 def file
67 nil
70 nil
68 end
71 end
69
72
70 # Copies the temporary file to its final location
73 # Copies the temporary file to its final location
71 # and computes its MD5 hash
74 # and computes its MD5 hash
72 def before_save
75 def before_save
73 if @temp_file && (@temp_file.size > 0)
76 if @temp_file && (@temp_file.size > 0)
74 logger.debug("saving '#{self.diskfile}'")
77 logger.debug("saving '#{self.diskfile}'")
75 md5 = Digest::MD5.new
78 md5 = Digest::MD5.new
76 File.open(diskfile, "wb") do |f|
79 File.open(diskfile, "wb") do |f|
77 buffer = ""
80 buffer = ""
78 while (buffer = @temp_file.read(8192))
81 while (buffer = @temp_file.read(8192))
79 f.write(buffer)
82 f.write(buffer)
80 md5.update(buffer)
83 md5.update(buffer)
81 end
84 end
82 end
85 end
83 self.digest = md5.hexdigest
86 self.digest = md5.hexdigest
84 end
87 end
85 # Don't save the content type if it's longer than the authorized length
88 # Don't save the content type if it's longer than the authorized length
86 if self.content_type && self.content_type.length > 255
89 if self.content_type && self.content_type.length > 255
87 self.content_type = nil
90 self.content_type = nil
88 end
91 end
89 end
92 end
90
93
91 # Deletes file on the disk
94 # Deletes file on the disk
92 def after_destroy
95 def after_destroy
93 File.delete(diskfile) if !filename.blank? && File.exist?(diskfile)
96 File.delete(diskfile) if !filename.blank? && File.exist?(diskfile)
94 end
97 end
95
98
96 # Returns file's location on disk
99 # Returns file's location on disk
97 def diskfile
100 def diskfile
98 "#{@@storage_path}/#{self.disk_filename}"
101 "#{@@storage_path}/#{self.disk_filename}"
99 end
102 end
100
103
101 def increment_download
104 def increment_download
102 increment!(:downloads)
105 increment!(:downloads)
103 end
106 end
104
107
105 def project
108 def project
106 container.project
109 container.project
107 end
110 end
108
111
109 def visible?(user=User.current)
112 def visible?(user=User.current)
110 container.attachments_visible?(user)
113 container.attachments_visible?(user)
111 end
114 end
112
115
113 def deletable?(user=User.current)
116 def deletable?(user=User.current)
114 container.attachments_deletable?(user)
117 container.attachments_deletable?(user)
115 end
118 end
116
119
117 def image?
120 def image?
118 self.filename =~ /\.(jpe?g|gif|png)$/i
121 self.filename =~ /\.(jpe?g|gif|png)$/i
119 end
122 end
120
123
121 def is_text?
124 def is_text?
122 Redmine::MimeType.is_type?('text', filename)
125 Redmine::MimeType.is_type?('text', filename)
123 end
126 end
124
127
125 def is_diff?
128 def is_diff?
126 self.filename =~ /\.(patch|diff)$/i
129 self.filename =~ /\.(patch|diff)$/i
127 end
130 end
128
131
129 # Returns true if the file is readable
132 # Returns true if the file is readable
130 def readable?
133 def readable?
131 File.readable?(diskfile)
134 File.readable?(diskfile)
132 end
135 end
133
136
134 private
137 private
135 def sanitize_filename(value)
138 def sanitize_filename(value)
136 # get only the filename, not the whole path
139 # get only the filename, not the whole path
137 just_filename = value.gsub(/^.*(\\|\/)/, '')
140 just_filename = value.gsub(/^.*(\\|\/)/, '')
138 # NOTE: File.basename doesn't work right with Windows paths on Unix
141 # NOTE: File.basename doesn't work right with Windows paths on Unix
139 # INCORRECT: just_filename = File.basename(value.gsub('\\\\', '/'))
142 # INCORRECT: just_filename = File.basename(value.gsub('\\\\', '/'))
140
143
141 # Finally, replace all non alphanumeric, hyphens or periods with underscore
144 # Finally, replace all non alphanumeric, hyphens or periods with underscore
142 @filename = just_filename.gsub(/[^\w\.\-]/,'_')
145 @filename = just_filename.gsub(/[^\w\.\-]/,'_')
143 end
146 end
144
147
145 # Returns an ASCII or hashed filename
148 # Returns an ASCII or hashed filename
146 def self.disk_filename(filename)
149 def self.disk_filename(filename)
147 df = DateTime.now.strftime("%y%m%d%H%M%S") + "_"
150 df = DateTime.now.strftime("%y%m%d%H%M%S") + "_"
148 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
151 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
149 df << filename
152 df << filename
150 else
153 else
151 df << Digest::MD5.hexdigest(filename)
154 df << Digest::MD5.hexdigest(filename)
152 # keep the extension if any
155 # keep the extension if any
153 df << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
156 df << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
154 end
157 end
155 df
158 df
156 end
159 end
157 end
160 end
@@ -1,81 +1,96
1 # redMine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2009 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine
18 module Redmine
19 module MimeType
19 module MimeType
20
20
21 MIME_TYPES = {
21 MIME_TYPES = {
22 'text/plain' => 'txt,tpl,properties,patch,diff,ini,readme,install,upgrade',
22 'text/plain' => 'txt,tpl,properties,patch,diff,ini,readme,install,upgrade',
23 'text/css' => 'css',
23 'text/css' => 'css',
24 'text/html' => 'html,htm,xhtml',
24 'text/html' => 'html,htm,xhtml',
25 'text/jsp' => 'jsp',
25 'text/jsp' => 'jsp',
26 'text/x-c' => 'c,cpp,cc,h,hh',
26 'text/x-c' => 'c,cpp,cc,h,hh',
27 'text/x-csharp' => 'cs',
27 'text/x-csharp' => 'cs',
28 'text/x-java' => 'java',
28 'text/x-java' => 'java',
29 'text/x-javascript' => 'js',
29 'text/x-javascript' => 'js',
30 'text/x-html-template' => 'rhtml',
30 'text/x-html-template' => 'rhtml',
31 'text/x-perl' => 'pl,pm',
31 'text/x-perl' => 'pl,pm',
32 'text/x-php' => 'php,php3,php4,php5',
32 'text/x-php' => 'php,php3,php4,php5',
33 'text/x-python' => 'py',
33 'text/x-python' => 'py',
34 'text/x-ruby' => 'rb,rbw,ruby,rake,erb',
34 'text/x-ruby' => 'rb,rbw,ruby,rake,erb',
35 'text/x-csh' => 'csh',
35 'text/x-csh' => 'csh',
36 'text/x-sh' => 'sh',
36 'text/x-sh' => 'sh',
37 'text/xml' => 'xml,xsd,mxml',
37 'text/xml' => 'xml,xsd,mxml',
38 'text/yaml' => 'yml,yaml',
38 'text/yaml' => 'yml,yaml',
39 'text/csv' => 'csv',
39 'image/gif' => 'gif',
40 'image/gif' => 'gif',
40 'image/jpeg' => 'jpg,jpeg,jpe',
41 'image/jpeg' => 'jpg,jpeg,jpe',
41 'image/png' => 'png',
42 'image/png' => 'png',
42 'image/tiff' => 'tiff,tif',
43 'image/tiff' => 'tiff,tif',
43 'image/x-ms-bmp' => 'bmp',
44 'image/x-ms-bmp' => 'bmp',
44 'image/x-xpixmap' => 'xpm',
45 'image/x-xpixmap' => 'xpm',
45 'application/pdf' => 'pdf',
46 'application/pdf' => 'pdf',
47 'application/rtf' => 'rtf',
48 'application/msword' => 'doc',
49 'application/vnd.ms-excel' => 'xls',
50 'application/vnd.ms-powerpoint' => 'ppt,pps',
51 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
52 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
53 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
54 'application/vnd.openxmlformats-officedocument.presentationml.slideshow' => 'ppsx',
55 'application/vnd.oasis.opendocument.spreadsheet' => 'ods',
56 'application/vnd.oasis.opendocument.text' => 'odt',
57 'application/vnd.oasis.opendocument.presentation' => 'odp',
58 'application/x-7z-compressed' => '7z',
59 'application/x-rar-compressed' => 'rar',
60 'application/x-tar' => 'tar',
46 'application/zip' => 'zip',
61 'application/zip' => 'zip',
47 'application/x-gzip' => 'gz',
62 'application/x-gzip' => 'gz',
48 }.freeze
63 }.freeze
49
64
50 EXTENSIONS = MIME_TYPES.inject({}) do |map, (type, exts)|
65 EXTENSIONS = MIME_TYPES.inject({}) do |map, (type, exts)|
51 exts.split(',').each {|ext| map[ext.strip] = type}
66 exts.split(',').each {|ext| map[ext.strip] = type}
52 map
67 map
53 end
68 end
54
69
55 # returns mime type for name or nil if unknown
70 # returns mime type for name or nil if unknown
56 def self.of(name)
71 def self.of(name)
57 return nil unless name
72 return nil unless name
58 m = name.to_s.match(/(^|\.)([^\.]+)$/)
73 m = name.to_s.match(/(^|\.)([^\.]+)$/)
59 EXTENSIONS[m[2].downcase] if m
74 EXTENSIONS[m[2].downcase] if m
60 end
75 end
61
76
62 # Returns the css class associated to
77 # Returns the css class associated to
63 # the mime type of name
78 # the mime type of name
64 def self.css_class_of(name)
79 def self.css_class_of(name)
65 mime = of(name)
80 mime = of(name)
66 mime && mime.gsub('/', '-')
81 mime && mime.gsub('/', '-')
67 end
82 end
68
83
69 def self.main_mimetype_of(name)
84 def self.main_mimetype_of(name)
70 mimetype = of(name)
85 mimetype = of(name)
71 mimetype.split('/').first if mimetype
86 mimetype.split('/').first if mimetype
72 end
87 end
73
88
74 # return true if mime-type for name is type/*
89 # return true if mime-type for name is type/*
75 # otherwise false
90 # otherwise false
76 def self.is_type?(type, name)
91 def self.is_type?(type, name)
77 main_mimetype = main_mimetype_of(name)
92 main_mimetype = main_mimetype_of(name)
78 type.to_s == main_mimetype
93 type.to_s == main_mimetype
79 end
94 end
80 end
95 end
81 end
96 end
@@ -1,764 +1,764
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'active_record'
18 require 'active_record'
19 require 'iconv'
19 require 'iconv'
20 require 'pp'
20 require 'pp'
21
21
22 namespace :redmine do
22 namespace :redmine do
23 desc 'Trac migration script'
23 desc 'Trac migration script'
24 task :migrate_from_trac => :environment do
24 task :migrate_from_trac => :environment do
25
25
26 module TracMigrate
26 module TracMigrate
27 TICKET_MAP = []
27 TICKET_MAP = []
28
28
29 DEFAULT_STATUS = IssueStatus.default
29 DEFAULT_STATUS = IssueStatus.default
30 assigned_status = IssueStatus.find_by_position(2)
30 assigned_status = IssueStatus.find_by_position(2)
31 resolved_status = IssueStatus.find_by_position(3)
31 resolved_status = IssueStatus.find_by_position(3)
32 feedback_status = IssueStatus.find_by_position(4)
32 feedback_status = IssueStatus.find_by_position(4)
33 closed_status = IssueStatus.find :first, :conditions => { :is_closed => true }
33 closed_status = IssueStatus.find :first, :conditions => { :is_closed => true }
34 STATUS_MAPPING = {'new' => DEFAULT_STATUS,
34 STATUS_MAPPING = {'new' => DEFAULT_STATUS,
35 'reopened' => feedback_status,
35 'reopened' => feedback_status,
36 'assigned' => assigned_status,
36 'assigned' => assigned_status,
37 'closed' => closed_status
37 'closed' => closed_status
38 }
38 }
39
39
40 priorities = IssuePriority.all
40 priorities = IssuePriority.all
41 DEFAULT_PRIORITY = priorities[0]
41 DEFAULT_PRIORITY = priorities[0]
42 PRIORITY_MAPPING = {'lowest' => priorities[0],
42 PRIORITY_MAPPING = {'lowest' => priorities[0],
43 'low' => priorities[0],
43 'low' => priorities[0],
44 'normal' => priorities[1],
44 'normal' => priorities[1],
45 'high' => priorities[2],
45 'high' => priorities[2],
46 'highest' => priorities[3],
46 'highest' => priorities[3],
47 # ---
47 # ---
48 'trivial' => priorities[0],
48 'trivial' => priorities[0],
49 'minor' => priorities[1],
49 'minor' => priorities[1],
50 'major' => priorities[2],
50 'major' => priorities[2],
51 'critical' => priorities[3],
51 'critical' => priorities[3],
52 'blocker' => priorities[4]
52 'blocker' => priorities[4]
53 }
53 }
54
54
55 TRACKER_BUG = Tracker.find_by_position(1)
55 TRACKER_BUG = Tracker.find_by_position(1)
56 TRACKER_FEATURE = Tracker.find_by_position(2)
56 TRACKER_FEATURE = Tracker.find_by_position(2)
57 DEFAULT_TRACKER = TRACKER_BUG
57 DEFAULT_TRACKER = TRACKER_BUG
58 TRACKER_MAPPING = {'defect' => TRACKER_BUG,
58 TRACKER_MAPPING = {'defect' => TRACKER_BUG,
59 'enhancement' => TRACKER_FEATURE,
59 'enhancement' => TRACKER_FEATURE,
60 'task' => TRACKER_FEATURE,
60 'task' => TRACKER_FEATURE,
61 'patch' =>TRACKER_FEATURE
61 'patch' =>TRACKER_FEATURE
62 }
62 }
63
63
64 roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC')
64 roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC')
65 manager_role = roles[0]
65 manager_role = roles[0]
66 developer_role = roles[1]
66 developer_role = roles[1]
67 DEFAULT_ROLE = roles.last
67 DEFAULT_ROLE = roles.last
68 ROLE_MAPPING = {'admin' => manager_role,
68 ROLE_MAPPING = {'admin' => manager_role,
69 'developer' => developer_role
69 'developer' => developer_role
70 }
70 }
71
71
72 class ::Time
72 class ::Time
73 class << self
73 class << self
74 alias :real_now :now
74 alias :real_now :now
75 def now
75 def now
76 real_now - @fake_diff.to_i
76 real_now - @fake_diff.to_i
77 end
77 end
78 def fake(time)
78 def fake(time)
79 @fake_diff = real_now - time
79 @fake_diff = real_now - time
80 res = yield
80 res = yield
81 @fake_diff = 0
81 @fake_diff = 0
82 res
82 res
83 end
83 end
84 end
84 end
85 end
85 end
86
86
87 class TracComponent < ActiveRecord::Base
87 class TracComponent < ActiveRecord::Base
88 set_table_name :component
88 set_table_name :component
89 end
89 end
90
90
91 class TracMilestone < ActiveRecord::Base
91 class TracMilestone < ActiveRecord::Base
92 set_table_name :milestone
92 set_table_name :milestone
93 # If this attribute is set a milestone has a defined target timepoint
93 # If this attribute is set a milestone has a defined target timepoint
94 def due
94 def due
95 if read_attribute(:due) && read_attribute(:due) > 0
95 if read_attribute(:due) && read_attribute(:due) > 0
96 Time.at(read_attribute(:due)).to_date
96 Time.at(read_attribute(:due)).to_date
97 else
97 else
98 nil
98 nil
99 end
99 end
100 end
100 end
101 # This is the real timepoint at which the milestone has finished.
101 # This is the real timepoint at which the milestone has finished.
102 def completed
102 def completed
103 if read_attribute(:completed) && read_attribute(:completed) > 0
103 if read_attribute(:completed) && read_attribute(:completed) > 0
104 Time.at(read_attribute(:completed)).to_date
104 Time.at(read_attribute(:completed)).to_date
105 else
105 else
106 nil
106 nil
107 end
107 end
108 end
108 end
109
109
110 def description
110 def description
111 # Attribute is named descr in Trac v0.8.x
111 # Attribute is named descr in Trac v0.8.x
112 has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description)
112 has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description)
113 end
113 end
114 end
114 end
115
115
116 class TracTicketCustom < ActiveRecord::Base
116 class TracTicketCustom < ActiveRecord::Base
117 set_table_name :ticket_custom
117 set_table_name :ticket_custom
118 end
118 end
119
119
120 class TracAttachment < ActiveRecord::Base
120 class TracAttachment < ActiveRecord::Base
121 set_table_name :attachment
121 set_table_name :attachment
122 set_inheritance_column :none
122 set_inheritance_column :none
123
123
124 def time; Time.at(read_attribute(:time)) end
124 def time; Time.at(read_attribute(:time)) end
125
125
126 def original_filename
126 def original_filename
127 filename
127 filename
128 end
128 end
129
129
130 def content_type
130 def content_type
131 Redmine::MimeType.of(filename) || ''
131 ''
132 end
132 end
133
133
134 def exist?
134 def exist?
135 File.file? trac_fullpath
135 File.file? trac_fullpath
136 end
136 end
137
137
138 def open
138 def open
139 File.open("#{trac_fullpath}", 'rb') {|f|
139 File.open("#{trac_fullpath}", 'rb') {|f|
140 @file = f
140 @file = f
141 yield self
141 yield self
142 }
142 }
143 end
143 end
144
144
145 def read(*args)
145 def read(*args)
146 @file.read(*args)
146 @file.read(*args)
147 end
147 end
148
148
149 def description
149 def description
150 read_attribute(:description).to_s.slice(0,255)
150 read_attribute(:description).to_s.slice(0,255)
151 end
151 end
152
152
153 private
153 private
154 def trac_fullpath
154 def trac_fullpath
155 attachment_type = read_attribute(:type)
155 attachment_type = read_attribute(:type)
156 trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
156 trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
157 "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
157 "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
158 end
158 end
159 end
159 end
160
160
161 class TracTicket < ActiveRecord::Base
161 class TracTicket < ActiveRecord::Base
162 set_table_name :ticket
162 set_table_name :ticket
163 set_inheritance_column :none
163 set_inheritance_column :none
164
164
165 # ticket changes: only migrate status changes and comments
165 # ticket changes: only migrate status changes and comments
166 has_many :changes, :class_name => "TracTicketChange", :foreign_key => :ticket
166 has_many :changes, :class_name => "TracTicketChange", :foreign_key => :ticket
167 has_many :attachments, :class_name => "TracAttachment",
167 has_many :attachments, :class_name => "TracAttachment",
168 :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
168 :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
169 " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'ticket'" +
169 " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'ticket'" +
170 ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{id}\''
170 ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{id}\''
171 has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket
171 has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket
172
172
173 def ticket_type
173 def ticket_type
174 read_attribute(:type)
174 read_attribute(:type)
175 end
175 end
176
176
177 def summary
177 def summary
178 read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary)
178 read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary)
179 end
179 end
180
180
181 def description
181 def description
182 read_attribute(:description).blank? ? summary : read_attribute(:description)
182 read_attribute(:description).blank? ? summary : read_attribute(:description)
183 end
183 end
184
184
185 def time; Time.at(read_attribute(:time)) end
185 def time; Time.at(read_attribute(:time)) end
186 def changetime; Time.at(read_attribute(:changetime)) end
186 def changetime; Time.at(read_attribute(:changetime)) end
187 end
187 end
188
188
189 class TracTicketChange < ActiveRecord::Base
189 class TracTicketChange < ActiveRecord::Base
190 set_table_name :ticket_change
190 set_table_name :ticket_change
191
191
192 def time; Time.at(read_attribute(:time)) end
192 def time; Time.at(read_attribute(:time)) end
193 end
193 end
194
194
195 TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
195 TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
196 TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
196 TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
197 TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
197 TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
198 TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
198 TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
199 TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
199 TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
200 WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
200 WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
201 CamelCase TitleIndex)
201 CamelCase TitleIndex)
202
202
203 class TracWikiPage < ActiveRecord::Base
203 class TracWikiPage < ActiveRecord::Base
204 set_table_name :wiki
204 set_table_name :wiki
205 set_primary_key :name
205 set_primary_key :name
206
206
207 has_many :attachments, :class_name => "TracAttachment",
207 has_many :attachments, :class_name => "TracAttachment",
208 :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
208 :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
209 " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'wiki'" +
209 " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'wiki'" +
210 ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{id}\''
210 ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{id}\''
211
211
212 def self.columns
212 def self.columns
213 # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
213 # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
214 super.select {|column| column.name.to_s != 'readonly'}
214 super.select {|column| column.name.to_s != 'readonly'}
215 end
215 end
216
216
217 def time; Time.at(read_attribute(:time)) end
217 def time; Time.at(read_attribute(:time)) end
218 end
218 end
219
219
220 class TracPermission < ActiveRecord::Base
220 class TracPermission < ActiveRecord::Base
221 set_table_name :permission
221 set_table_name :permission
222 end
222 end
223
223
224 class TracSessionAttribute < ActiveRecord::Base
224 class TracSessionAttribute < ActiveRecord::Base
225 set_table_name :session_attribute
225 set_table_name :session_attribute
226 end
226 end
227
227
228 def self.find_or_create_user(username, project_member = false)
228 def self.find_or_create_user(username, project_member = false)
229 return User.anonymous if username.blank?
229 return User.anonymous if username.blank?
230
230
231 u = User.find_by_login(username)
231 u = User.find_by_login(username)
232 if !u
232 if !u
233 # Create a new user if not found
233 # Create a new user if not found
234 mail = username[0,limit_for(User, 'mail')]
234 mail = username[0,limit_for(User, 'mail')]
235 if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email')
235 if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email')
236 mail = mail_attr.value
236 mail = mail_attr.value
237 end
237 end
238 mail = "#{mail}@foo.bar" unless mail.include?("@")
238 mail = "#{mail}@foo.bar" unless mail.include?("@")
239
239
240 name = username
240 name = username
241 if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
241 if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
242 name = name_attr.value
242 name = name_attr.value
243 end
243 end
244 name =~ (/(.*)(\s+\w+)?/)
244 name =~ (/(.*)(\s+\w+)?/)
245 fn = $1.strip
245 fn = $1.strip
246 ln = ($2 || '-').strip
246 ln = ($2 || '-').strip
247
247
248 u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
248 u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
249 :firstname => fn[0, limit_for(User, 'firstname')].gsub(/[^\w\s\'\-]/i, '-'),
249 :firstname => fn[0, limit_for(User, 'firstname')].gsub(/[^\w\s\'\-]/i, '-'),
250 :lastname => ln[0, limit_for(User, 'lastname')].gsub(/[^\w\s\'\-]/i, '-')
250 :lastname => ln[0, limit_for(User, 'lastname')].gsub(/[^\w\s\'\-]/i, '-')
251
251
252 u.login = username[0,limit_for(User, 'login')].gsub(/[^a-z0-9_\-@\.]/i, '-')
252 u.login = username[0,limit_for(User, 'login')].gsub(/[^a-z0-9_\-@\.]/i, '-')
253 u.password = 'trac'
253 u.password = 'trac'
254 u.admin = true if TracPermission.find_by_username_and_action(username, 'admin')
254 u.admin = true if TracPermission.find_by_username_and_action(username, 'admin')
255 # finally, a default user is used if the new user is not valid
255 # finally, a default user is used if the new user is not valid
256 u = User.find(:first) unless u.save
256 u = User.find(:first) unless u.save
257 end
257 end
258 # Make sure he is a member of the project
258 # Make sure he is a member of the project
259 if project_member && !u.member_of?(@target_project)
259 if project_member && !u.member_of?(@target_project)
260 role = DEFAULT_ROLE
260 role = DEFAULT_ROLE
261 if u.admin
261 if u.admin
262 role = ROLE_MAPPING['admin']
262 role = ROLE_MAPPING['admin']
263 elsif TracPermission.find_by_username_and_action(username, 'developer')
263 elsif TracPermission.find_by_username_and_action(username, 'developer')
264 role = ROLE_MAPPING['developer']
264 role = ROLE_MAPPING['developer']
265 end
265 end
266 Member.create(:user => u, :project => @target_project, :roles => [role])
266 Member.create(:user => u, :project => @target_project, :roles => [role])
267 u.reload
267 u.reload
268 end
268 end
269 u
269 u
270 end
270 end
271
271
272 # Basic wiki syntax conversion
272 # Basic wiki syntax conversion
273 def self.convert_wiki_text(text)
273 def self.convert_wiki_text(text)
274 # Titles
274 # Titles
275 text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
275 text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
276 # External Links
276 # External Links
277 text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
277 text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
278 # Ticket links:
278 # Ticket links:
279 # [ticket:234 Text],[ticket:234 This is a test]
279 # [ticket:234 Text],[ticket:234 This is a test]
280 text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
280 text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
281 # ticket:1234
281 # ticket:1234
282 # #1 is working cause Redmine uses the same syntax.
282 # #1 is working cause Redmine uses the same syntax.
283 text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
283 text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
284 # Milestone links:
284 # Milestone links:
285 # [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
285 # [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
286 # The text "Milestone 0.1.0 (Mercury)" is not converted,
286 # The text "Milestone 0.1.0 (Mercury)" is not converted,
287 # cause Redmine's wiki does not support this.
287 # cause Redmine's wiki does not support this.
288 text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
288 text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
289 # [milestone:"0.1.0 Mercury"]
289 # [milestone:"0.1.0 Mercury"]
290 text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
290 text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
291 text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
291 text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
292 # milestone:0.1.0
292 # milestone:0.1.0
293 text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
293 text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
294 text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
294 text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
295 # Internal Links
295 # Internal Links
296 text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
296 text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
297 text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
297 text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
298 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
298 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
299 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
299 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
300 text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
300 text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
301 text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
301 text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
302
302
303 # Links to pages UsingJustWikiCaps
303 # Links to pages UsingJustWikiCaps
304 text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
304 text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
305 # Normalize things that were supposed to not be links
305 # Normalize things that were supposed to not be links
306 # like !NotALink
306 # like !NotALink
307 text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
307 text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
308 # Revisions links
308 # Revisions links
309 text = text.gsub(/\[(\d+)\]/, 'r\1')
309 text = text.gsub(/\[(\d+)\]/, 'r\1')
310 # Ticket number re-writing
310 # Ticket number re-writing
311 text = text.gsub(/#(\d+)/) do |s|
311 text = text.gsub(/#(\d+)/) do |s|
312 if $1.length < 10
312 if $1.length < 10
313 TICKET_MAP[$1.to_i] ||= $1
313 TICKET_MAP[$1.to_i] ||= $1
314 "\##{TICKET_MAP[$1.to_i] || $1}"
314 "\##{TICKET_MAP[$1.to_i] || $1}"
315 else
315 else
316 s
316 s
317 end
317 end
318 end
318 end
319 # We would like to convert the Code highlighting too
319 # We would like to convert the Code highlighting too
320 # This will go into the next line.
320 # This will go into the next line.
321 shebang_line = false
321 shebang_line = false
322 # Reguar expression for start of code
322 # Reguar expression for start of code
323 pre_re = /\{\{\{/
323 pre_re = /\{\{\{/
324 # Code hightlighing...
324 # Code hightlighing...
325 shebang_re = /^\#\!([a-z]+)/
325 shebang_re = /^\#\!([a-z]+)/
326 # Regular expression for end of code
326 # Regular expression for end of code
327 pre_end_re = /\}\}\}/
327 pre_end_re = /\}\}\}/
328
328
329 # Go through the whole text..extract it line by line
329 # Go through the whole text..extract it line by line
330 text = text.gsub(/^(.*)$/) do |line|
330 text = text.gsub(/^(.*)$/) do |line|
331 m_pre = pre_re.match(line)
331 m_pre = pre_re.match(line)
332 if m_pre
332 if m_pre
333 line = '<pre>'
333 line = '<pre>'
334 else
334 else
335 m_sl = shebang_re.match(line)
335 m_sl = shebang_re.match(line)
336 if m_sl
336 if m_sl
337 shebang_line = true
337 shebang_line = true
338 line = '<code class="' + m_sl[1] + '">'
338 line = '<code class="' + m_sl[1] + '">'
339 end
339 end
340 m_pre_end = pre_end_re.match(line)
340 m_pre_end = pre_end_re.match(line)
341 if m_pre_end
341 if m_pre_end
342 line = '</pre>'
342 line = '</pre>'
343 if shebang_line
343 if shebang_line
344 line = '</code>' + line
344 line = '</code>' + line
345 end
345 end
346 end
346 end
347 end
347 end
348 line
348 line
349 end
349 end
350
350
351 # Highlighting
351 # Highlighting
352 text = text.gsub(/'''''([^\s])/, '_*\1')
352 text = text.gsub(/'''''([^\s])/, '_*\1')
353 text = text.gsub(/([^\s])'''''/, '\1*_')
353 text = text.gsub(/([^\s])'''''/, '\1*_')
354 text = text.gsub(/'''/, '*')
354 text = text.gsub(/'''/, '*')
355 text = text.gsub(/''/, '_')
355 text = text.gsub(/''/, '_')
356 text = text.gsub(/__/, '+')
356 text = text.gsub(/__/, '+')
357 text = text.gsub(/~~/, '-')
357 text = text.gsub(/~~/, '-')
358 text = text.gsub(/`/, '@')
358 text = text.gsub(/`/, '@')
359 text = text.gsub(/,,/, '~')
359 text = text.gsub(/,,/, '~')
360 # Lists
360 # Lists
361 text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
361 text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
362
362
363 text
363 text
364 end
364 end
365
365
366 def self.migrate
366 def self.migrate
367 establish_connection
367 establish_connection
368
368
369 # Quick database test
369 # Quick database test
370 TracComponent.count
370 TracComponent.count
371
371
372 migrated_components = 0
372 migrated_components = 0
373 migrated_milestones = 0
373 migrated_milestones = 0
374 migrated_tickets = 0
374 migrated_tickets = 0
375 migrated_custom_values = 0
375 migrated_custom_values = 0
376 migrated_ticket_attachments = 0
376 migrated_ticket_attachments = 0
377 migrated_wiki_edits = 0
377 migrated_wiki_edits = 0
378 migrated_wiki_attachments = 0
378 migrated_wiki_attachments = 0
379
379
380 #Wiki system initializing...
380 #Wiki system initializing...
381 @target_project.wiki.destroy if @target_project.wiki
381 @target_project.wiki.destroy if @target_project.wiki
382 @target_project.reload
382 @target_project.reload
383 wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
383 wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
384 wiki_edit_count = 0
384 wiki_edit_count = 0
385
385
386 # Components
386 # Components
387 print "Migrating components"
387 print "Migrating components"
388 issues_category_map = {}
388 issues_category_map = {}
389 TracComponent.find(:all).each do |component|
389 TracComponent.find(:all).each do |component|
390 print '.'
390 print '.'
391 STDOUT.flush
391 STDOUT.flush
392 c = IssueCategory.new :project => @target_project,
392 c = IssueCategory.new :project => @target_project,
393 :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
393 :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
394 next unless c.save
394 next unless c.save
395 issues_category_map[component.name] = c
395 issues_category_map[component.name] = c
396 migrated_components += 1
396 migrated_components += 1
397 end
397 end
398 puts
398 puts
399
399
400 # Milestones
400 # Milestones
401 print "Migrating milestones"
401 print "Migrating milestones"
402 version_map = {}
402 version_map = {}
403 TracMilestone.find(:all).each do |milestone|
403 TracMilestone.find(:all).each do |milestone|
404 print '.'
404 print '.'
405 STDOUT.flush
405 STDOUT.flush
406 # First we try to find the wiki page...
406 # First we try to find the wiki page...
407 p = wiki.find_or_new_page(milestone.name.to_s)
407 p = wiki.find_or_new_page(milestone.name.to_s)
408 p.content = WikiContent.new(:page => p) if p.new_record?
408 p.content = WikiContent.new(:page => p) if p.new_record?
409 p.content.text = milestone.description.to_s
409 p.content.text = milestone.description.to_s
410 p.content.author = find_or_create_user('trac')
410 p.content.author = find_or_create_user('trac')
411 p.content.comments = 'Milestone'
411 p.content.comments = 'Milestone'
412 p.save
412 p.save
413
413
414 v = Version.new :project => @target_project,
414 v = Version.new :project => @target_project,
415 :name => encode(milestone.name[0, limit_for(Version, 'name')]),
415 :name => encode(milestone.name[0, limit_for(Version, 'name')]),
416 :description => nil,
416 :description => nil,
417 :wiki_page_title => milestone.name.to_s,
417 :wiki_page_title => milestone.name.to_s,
418 :effective_date => milestone.completed
418 :effective_date => milestone.completed
419
419
420 next unless v.save
420 next unless v.save
421 version_map[milestone.name] = v
421 version_map[milestone.name] = v
422 migrated_milestones += 1
422 migrated_milestones += 1
423 end
423 end
424 puts
424 puts
425
425
426 # Custom fields
426 # Custom fields
427 # TODO: read trac.ini instead
427 # TODO: read trac.ini instead
428 print "Migrating custom fields"
428 print "Migrating custom fields"
429 custom_field_map = {}
429 custom_field_map = {}
430 TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
430 TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
431 print '.'
431 print '.'
432 STDOUT.flush
432 STDOUT.flush
433 # Redmine custom field name
433 # Redmine custom field name
434 field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
434 field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
435 # Find if the custom already exists in Redmine
435 # Find if the custom already exists in Redmine
436 f = IssueCustomField.find_by_name(field_name)
436 f = IssueCustomField.find_by_name(field_name)
437 # Or create a new one
437 # Or create a new one
438 f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
438 f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
439 :field_format => 'string')
439 :field_format => 'string')
440
440
441 next if f.new_record?
441 next if f.new_record?
442 f.trackers = Tracker.find(:all)
442 f.trackers = Tracker.find(:all)
443 f.projects << @target_project
443 f.projects << @target_project
444 custom_field_map[field.name] = f
444 custom_field_map[field.name] = f
445 end
445 end
446 puts
446 puts
447
447
448 # Trac 'resolution' field as a Redmine custom field
448 # Trac 'resolution' field as a Redmine custom field
449 r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
449 r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
450 r = IssueCustomField.new(:name => 'Resolution',
450 r = IssueCustomField.new(:name => 'Resolution',
451 :field_format => 'list',
451 :field_format => 'list',
452 :is_filter => true) if r.nil?
452 :is_filter => true) if r.nil?
453 r.trackers = Tracker.find(:all)
453 r.trackers = Tracker.find(:all)
454 r.projects << @target_project
454 r.projects << @target_project
455 r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
455 r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
456 r.save!
456 r.save!
457 custom_field_map['resolution'] = r
457 custom_field_map['resolution'] = r
458
458
459 # Tickets
459 # Tickets
460 print "Migrating tickets"
460 print "Migrating tickets"
461 TracTicket.find_each(:batch_size => 200) do |ticket|
461 TracTicket.find_each(:batch_size => 200) do |ticket|
462 print '.'
462 print '.'
463 STDOUT.flush
463 STDOUT.flush
464 i = Issue.new :project => @target_project,
464 i = Issue.new :project => @target_project,
465 :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
465 :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
466 :description => convert_wiki_text(encode(ticket.description)),
466 :description => convert_wiki_text(encode(ticket.description)),
467 :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
467 :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
468 :created_on => ticket.time
468 :created_on => ticket.time
469 i.author = find_or_create_user(ticket.reporter)
469 i.author = find_or_create_user(ticket.reporter)
470 i.category = issues_category_map[ticket.component] unless ticket.component.blank?
470 i.category = issues_category_map[ticket.component] unless ticket.component.blank?
471 i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
471 i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
472 i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
472 i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
473 i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
473 i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
474 i.id = ticket.id unless Issue.exists?(ticket.id)
474 i.id = ticket.id unless Issue.exists?(ticket.id)
475 next unless Time.fake(ticket.changetime) { i.save }
475 next unless Time.fake(ticket.changetime) { i.save }
476 TICKET_MAP[ticket.id] = i.id
476 TICKET_MAP[ticket.id] = i.id
477 migrated_tickets += 1
477 migrated_tickets += 1
478
478
479 # Owner
479 # Owner
480 unless ticket.owner.blank?
480 unless ticket.owner.blank?
481 i.assigned_to = find_or_create_user(ticket.owner, true)
481 i.assigned_to = find_or_create_user(ticket.owner, true)
482 Time.fake(ticket.changetime) { i.save }
482 Time.fake(ticket.changetime) { i.save }
483 end
483 end
484
484
485 # Comments and status/resolution changes
485 # Comments and status/resolution changes
486 ticket.changes.group_by(&:time).each do |time, changeset|
486 ticket.changes.group_by(&:time).each do |time, changeset|
487 status_change = changeset.select {|change| change.field == 'status'}.first
487 status_change = changeset.select {|change| change.field == 'status'}.first
488 resolution_change = changeset.select {|change| change.field == 'resolution'}.first
488 resolution_change = changeset.select {|change| change.field == 'resolution'}.first
489 comment_change = changeset.select {|change| change.field == 'comment'}.first
489 comment_change = changeset.select {|change| change.field == 'comment'}.first
490
490
491 n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
491 n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
492 :created_on => time
492 :created_on => time
493 n.user = find_or_create_user(changeset.first.author)
493 n.user = find_or_create_user(changeset.first.author)
494 n.journalized = i
494 n.journalized = i
495 if status_change &&
495 if status_change &&
496 STATUS_MAPPING[status_change.oldvalue] &&
496 STATUS_MAPPING[status_change.oldvalue] &&
497 STATUS_MAPPING[status_change.newvalue] &&
497 STATUS_MAPPING[status_change.newvalue] &&
498 (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
498 (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
499 n.details << JournalDetail.new(:property => 'attr',
499 n.details << JournalDetail.new(:property => 'attr',
500 :prop_key => 'status_id',
500 :prop_key => 'status_id',
501 :old_value => STATUS_MAPPING[status_change.oldvalue].id,
501 :old_value => STATUS_MAPPING[status_change.oldvalue].id,
502 :value => STATUS_MAPPING[status_change.newvalue].id)
502 :value => STATUS_MAPPING[status_change.newvalue].id)
503 end
503 end
504 if resolution_change
504 if resolution_change
505 n.details << JournalDetail.new(:property => 'cf',
505 n.details << JournalDetail.new(:property => 'cf',
506 :prop_key => custom_field_map['resolution'].id,
506 :prop_key => custom_field_map['resolution'].id,
507 :old_value => resolution_change.oldvalue,
507 :old_value => resolution_change.oldvalue,
508 :value => resolution_change.newvalue)
508 :value => resolution_change.newvalue)
509 end
509 end
510 n.save unless n.details.empty? && n.notes.blank?
510 n.save unless n.details.empty? && n.notes.blank?
511 end
511 end
512
512
513 # Attachments
513 # Attachments
514 ticket.attachments.each do |attachment|
514 ticket.attachments.each do |attachment|
515 next unless attachment.exist?
515 next unless attachment.exist?
516 attachment.open {
516 attachment.open {
517 a = Attachment.new :created_on => attachment.time
517 a = Attachment.new :created_on => attachment.time
518 a.file = attachment
518 a.file = attachment
519 a.author = find_or_create_user(attachment.author)
519 a.author = find_or_create_user(attachment.author)
520 a.container = i
520 a.container = i
521 a.description = attachment.description
521 a.description = attachment.description
522 migrated_ticket_attachments += 1 if a.save
522 migrated_ticket_attachments += 1 if a.save
523 }
523 }
524 end
524 end
525
525
526 # Custom fields
526 # Custom fields
527 custom_values = ticket.customs.inject({}) do |h, custom|
527 custom_values = ticket.customs.inject({}) do |h, custom|
528 if custom_field = custom_field_map[custom.name]
528 if custom_field = custom_field_map[custom.name]
529 h[custom_field.id] = custom.value
529 h[custom_field.id] = custom.value
530 migrated_custom_values += 1
530 migrated_custom_values += 1
531 end
531 end
532 h
532 h
533 end
533 end
534 if custom_field_map['resolution'] && !ticket.resolution.blank?
534 if custom_field_map['resolution'] && !ticket.resolution.blank?
535 custom_values[custom_field_map['resolution'].id] = ticket.resolution
535 custom_values[custom_field_map['resolution'].id] = ticket.resolution
536 end
536 end
537 i.custom_field_values = custom_values
537 i.custom_field_values = custom_values
538 i.save_custom_field_values
538 i.save_custom_field_values
539 end
539 end
540
540
541 # update issue id sequence if needed (postgresql)
541 # update issue id sequence if needed (postgresql)
542 Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
542 Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
543 puts
543 puts
544
544
545 # Wiki
545 # Wiki
546 print "Migrating wiki"
546 print "Migrating wiki"
547 if wiki.save
547 if wiki.save
548 TracWikiPage.find(:all, :order => 'name, version').each do |page|
548 TracWikiPage.find(:all, :order => 'name, version').each do |page|
549 # Do not migrate Trac manual wiki pages
549 # Do not migrate Trac manual wiki pages
550 next if TRAC_WIKI_PAGES.include?(page.name)
550 next if TRAC_WIKI_PAGES.include?(page.name)
551 wiki_edit_count += 1
551 wiki_edit_count += 1
552 print '.'
552 print '.'
553 STDOUT.flush
553 STDOUT.flush
554 p = wiki.find_or_new_page(page.name)
554 p = wiki.find_or_new_page(page.name)
555 p.content = WikiContent.new(:page => p) if p.new_record?
555 p.content = WikiContent.new(:page => p) if p.new_record?
556 p.content.text = page.text
556 p.content.text = page.text
557 p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
557 p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
558 p.content.comments = page.comment
558 p.content.comments = page.comment
559 Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
559 Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
560
560
561 next if p.content.new_record?
561 next if p.content.new_record?
562 migrated_wiki_edits += 1
562 migrated_wiki_edits += 1
563
563
564 # Attachments
564 # Attachments
565 page.attachments.each do |attachment|
565 page.attachments.each do |attachment|
566 next unless attachment.exist?
566 next unless attachment.exist?
567 next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page
567 next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page
568 attachment.open {
568 attachment.open {
569 a = Attachment.new :created_on => attachment.time
569 a = Attachment.new :created_on => attachment.time
570 a.file = attachment
570 a.file = attachment
571 a.author = find_or_create_user(attachment.author)
571 a.author = find_or_create_user(attachment.author)
572 a.description = attachment.description
572 a.description = attachment.description
573 a.container = p
573 a.container = p
574 migrated_wiki_attachments += 1 if a.save
574 migrated_wiki_attachments += 1 if a.save
575 }
575 }
576 end
576 end
577 end
577 end
578
578
579 wiki.reload
579 wiki.reload
580 wiki.pages.each do |page|
580 wiki.pages.each do |page|
581 page.content.text = convert_wiki_text(page.content.text)
581 page.content.text = convert_wiki_text(page.content.text)
582 Time.fake(page.content.updated_on) { page.content.save }
582 Time.fake(page.content.updated_on) { page.content.save }
583 end
583 end
584 end
584 end
585 puts
585 puts
586
586
587 puts
587 puts
588 puts "Components: #{migrated_components}/#{TracComponent.count}"
588 puts "Components: #{migrated_components}/#{TracComponent.count}"
589 puts "Milestones: #{migrated_milestones}/#{TracMilestone.count}"
589 puts "Milestones: #{migrated_milestones}/#{TracMilestone.count}"
590 puts "Tickets: #{migrated_tickets}/#{TracTicket.count}"
590 puts "Tickets: #{migrated_tickets}/#{TracTicket.count}"
591 puts "Ticket files: #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
591 puts "Ticket files: #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
592 puts "Custom values: #{migrated_custom_values}/#{TracTicketCustom.count}"
592 puts "Custom values: #{migrated_custom_values}/#{TracTicketCustom.count}"
593 puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edit_count}"
593 puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edit_count}"
594 puts "Wiki files: #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
594 puts "Wiki files: #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
595 end
595 end
596
596
597 def self.limit_for(klass, attribute)
597 def self.limit_for(klass, attribute)
598 klass.columns_hash[attribute.to_s].limit
598 klass.columns_hash[attribute.to_s].limit
599 end
599 end
600
600
601 def self.encoding(charset)
601 def self.encoding(charset)
602 @ic = Iconv.new('UTF-8', charset)
602 @ic = Iconv.new('UTF-8', charset)
603 rescue Iconv::InvalidEncoding
603 rescue Iconv::InvalidEncoding
604 puts "Invalid encoding!"
604 puts "Invalid encoding!"
605 return false
605 return false
606 end
606 end
607
607
608 def self.set_trac_directory(path)
608 def self.set_trac_directory(path)
609 @@trac_directory = path
609 @@trac_directory = path
610 raise "This directory doesn't exist!" unless File.directory?(path)
610 raise "This directory doesn't exist!" unless File.directory?(path)
611 raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory)
611 raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory)
612 @@trac_directory
612 @@trac_directory
613 rescue Exception => e
613 rescue Exception => e
614 puts e
614 puts e
615 return false
615 return false
616 end
616 end
617
617
618 def self.trac_directory
618 def self.trac_directory
619 @@trac_directory
619 @@trac_directory
620 end
620 end
621
621
622 def self.set_trac_adapter(adapter)
622 def self.set_trac_adapter(adapter)
623 return false if adapter.blank?
623 return false if adapter.blank?
624 raise "Unknown adapter: #{adapter}!" unless %w(sqlite sqlite3 mysql postgresql).include?(adapter)
624 raise "Unknown adapter: #{adapter}!" unless %w(sqlite sqlite3 mysql postgresql).include?(adapter)
625 # If adapter is sqlite or sqlite3, make sure that trac.db exists
625 # If adapter is sqlite or sqlite3, make sure that trac.db exists
626 raise "#{trac_db_path} doesn't exist!" if %w(sqlite sqlite3).include?(adapter) && !File.exist?(trac_db_path)
626 raise "#{trac_db_path} doesn't exist!" if %w(sqlite sqlite3).include?(adapter) && !File.exist?(trac_db_path)
627 @@trac_adapter = adapter
627 @@trac_adapter = adapter
628 rescue Exception => e
628 rescue Exception => e
629 puts e
629 puts e
630 return false
630 return false
631 end
631 end
632
632
633 def self.set_trac_db_host(host)
633 def self.set_trac_db_host(host)
634 return nil if host.blank?
634 return nil if host.blank?
635 @@trac_db_host = host
635 @@trac_db_host = host
636 end
636 end
637
637
638 def self.set_trac_db_port(port)
638 def self.set_trac_db_port(port)
639 return nil if port.to_i == 0
639 return nil if port.to_i == 0
640 @@trac_db_port = port.to_i
640 @@trac_db_port = port.to_i
641 end
641 end
642
642
643 def self.set_trac_db_name(name)
643 def self.set_trac_db_name(name)
644 return nil if name.blank?
644 return nil if name.blank?
645 @@trac_db_name = name
645 @@trac_db_name = name
646 end
646 end
647
647
648 def self.set_trac_db_username(username)
648 def self.set_trac_db_username(username)
649 @@trac_db_username = username
649 @@trac_db_username = username
650 end
650 end
651
651
652 def self.set_trac_db_password(password)
652 def self.set_trac_db_password(password)
653 @@trac_db_password = password
653 @@trac_db_password = password
654 end
654 end
655
655
656 def self.set_trac_db_schema(schema)
656 def self.set_trac_db_schema(schema)
657 @@trac_db_schema = schema
657 @@trac_db_schema = schema
658 end
658 end
659
659
660 mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password
660 mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password
661
661
662 def self.trac_db_path; "#{trac_directory}/db/trac.db" end
662 def self.trac_db_path; "#{trac_directory}/db/trac.db" end
663 def self.trac_attachments_directory; "#{trac_directory}/attachments" end
663 def self.trac_attachments_directory; "#{trac_directory}/attachments" end
664
664
665 def self.target_project_identifier(identifier)
665 def self.target_project_identifier(identifier)
666 project = Project.find_by_identifier(identifier)
666 project = Project.find_by_identifier(identifier)
667 if !project
667 if !project
668 # create the target project
668 # create the target project
669 project = Project.new :name => identifier.humanize,
669 project = Project.new :name => identifier.humanize,
670 :description => ''
670 :description => ''
671 project.identifier = identifier
671 project.identifier = identifier
672 puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
672 puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
673 # enable issues and wiki for the created project
673 # enable issues and wiki for the created project
674 project.enabled_module_names = ['issue_tracking', 'wiki']
674 project.enabled_module_names = ['issue_tracking', 'wiki']
675 else
675 else
676 puts
676 puts
677 puts "This project already exists in your Redmine database."
677 puts "This project already exists in your Redmine database."
678 print "Are you sure you want to append data to this project ? [Y/n] "
678 print "Are you sure you want to append data to this project ? [Y/n] "
679 exit if STDIN.gets.match(/^n$/i)
679 exit if STDIN.gets.match(/^n$/i)
680 end
680 end
681 project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
681 project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
682 project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
682 project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
683 @target_project = project.new_record? ? nil : project
683 @target_project = project.new_record? ? nil : project
684 end
684 end
685
685
686 def self.connection_params
686 def self.connection_params
687 if %w(sqlite sqlite3).include?(trac_adapter)
687 if %w(sqlite sqlite3).include?(trac_adapter)
688 {:adapter => trac_adapter,
688 {:adapter => trac_adapter,
689 :database => trac_db_path}
689 :database => trac_db_path}
690 else
690 else
691 {:adapter => trac_adapter,
691 {:adapter => trac_adapter,
692 :database => trac_db_name,
692 :database => trac_db_name,
693 :host => trac_db_host,
693 :host => trac_db_host,
694 :port => trac_db_port,
694 :port => trac_db_port,
695 :username => trac_db_username,
695 :username => trac_db_username,
696 :password => trac_db_password,
696 :password => trac_db_password,
697 :schema_search_path => trac_db_schema
697 :schema_search_path => trac_db_schema
698 }
698 }
699 end
699 end
700 end
700 end
701
701
702 def self.establish_connection
702 def self.establish_connection
703 constants.each do |const|
703 constants.each do |const|
704 klass = const_get(const)
704 klass = const_get(const)
705 next unless klass.respond_to? 'establish_connection'
705 next unless klass.respond_to? 'establish_connection'
706 klass.establish_connection connection_params
706 klass.establish_connection connection_params
707 end
707 end
708 end
708 end
709
709
710 private
710 private
711 def self.encode(text)
711 def self.encode(text)
712 @ic.iconv text
712 @ic.iconv text
713 rescue
713 rescue
714 text
714 text
715 end
715 end
716 end
716 end
717
717
718 puts
718 puts
719 if Redmine::DefaultData::Loader.no_data?
719 if Redmine::DefaultData::Loader.no_data?
720 puts "Redmine configuration need to be loaded before importing data."
720 puts "Redmine configuration need to be loaded before importing data."
721 puts "Please, run this first:"
721 puts "Please, run this first:"
722 puts
722 puts
723 puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
723 puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
724 exit
724 exit
725 end
725 end
726
726
727 puts "WARNING: a new project will be added to Redmine during this process."
727 puts "WARNING: a new project will be added to Redmine during this process."
728 print "Are you sure you want to continue ? [y/N] "
728 print "Are you sure you want to continue ? [y/N] "
729 break unless STDIN.gets.match(/^y$/i)
729 break unless STDIN.gets.match(/^y$/i)
730 puts
730 puts
731
731
732 def prompt(text, options = {}, &block)
732 def prompt(text, options = {}, &block)
733 default = options[:default] || ''
733 default = options[:default] || ''
734 while true
734 while true
735 print "#{text} [#{default}]: "
735 print "#{text} [#{default}]: "
736 value = STDIN.gets.chomp!
736 value = STDIN.gets.chomp!
737 value = default if value.blank?
737 value = default if value.blank?
738 break if yield value
738 break if yield value
739 end
739 end
740 end
740 end
741
741
742 DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
742 DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
743
743
744 prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
744 prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
745 prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite') {|adapter| TracMigrate.set_trac_adapter adapter}
745 prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite') {|adapter| TracMigrate.set_trac_adapter adapter}
746 unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
746 unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
747 prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
747 prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
748 prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
748 prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
749 prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name}
749 prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name}
750 prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema}
750 prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema}
751 prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username}
751 prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username}
752 prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
752 prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
753 end
753 end
754 prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
754 prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
755 prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
755 prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
756 puts
756 puts
757
757
758 # Turn off email notifications
758 # Turn off email notifications
759 Setting.notified_events = []
759 Setting.notified_events = []
760
760
761 TracMigrate.migrate
761 TracMigrate.migrate
762 end
762 end
763 end
763 end
764
764
@@ -1,142 +1,150
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.dirname(__FILE__) + '/../test_helper'
18 require File.dirname(__FILE__) + '/../test_helper'
19 require 'attachments_controller'
19 require 'attachments_controller'
20
20
21 # Re-raise errors caught by the controller.
21 # Re-raise errors caught by the controller.
22 class AttachmentsController; def rescue_action(e) raise e end; end
22 class AttachmentsController; def rescue_action(e) raise e end; end
23
23
24
24
25 class AttachmentsControllerTest < ActionController::TestCase
25 class AttachmentsControllerTest < ActionController::TestCase
26 fixtures :users, :projects, :roles, :members, :member_roles, :enabled_modules, :issues, :trackers, :attachments,
26 fixtures :users, :projects, :roles, :members, :member_roles, :enabled_modules, :issues, :trackers, :attachments,
27 :versions, :wiki_pages, :wikis, :documents
27 :versions, :wiki_pages, :wikis, :documents
28
28
29 def setup
29 def setup
30 @controller = AttachmentsController.new
30 @controller = AttachmentsController.new
31 @request = ActionController::TestRequest.new
31 @request = ActionController::TestRequest.new
32 @response = ActionController::TestResponse.new
32 @response = ActionController::TestResponse.new
33 Attachment.storage_path = "#{RAILS_ROOT}/test/fixtures/files"
33 Attachment.storage_path = "#{RAILS_ROOT}/test/fixtures/files"
34 User.current = nil
34 User.current = nil
35 end
35 end
36
36
37 def test_routing
37 def test_routing
38 assert_routing('/attachments/1', :controller => 'attachments', :action => 'show', :id => '1')
38 assert_routing('/attachments/1', :controller => 'attachments', :action => 'show', :id => '1')
39 assert_routing('/attachments/1/filename.ext', :controller => 'attachments', :action => 'show', :id => '1', :filename => 'filename.ext')
39 assert_routing('/attachments/1/filename.ext', :controller => 'attachments', :action => 'show', :id => '1', :filename => 'filename.ext')
40 assert_routing('/attachments/download/1', :controller => 'attachments', :action => 'download', :id => '1')
40 assert_routing('/attachments/download/1', :controller => 'attachments', :action => 'download', :id => '1')
41 assert_routing('/attachments/download/1/filename.ext', :controller => 'attachments', :action => 'download', :id => '1', :filename => 'filename.ext')
41 assert_routing('/attachments/download/1/filename.ext', :controller => 'attachments', :action => 'download', :id => '1', :filename => 'filename.ext')
42 end
42 end
43
43
44 def test_recognizes
44 def test_recognizes
45 assert_recognizes({:controller => 'attachments', :action => 'show', :id => '1'}, '/attachments/1')
45 assert_recognizes({:controller => 'attachments', :action => 'show', :id => '1'}, '/attachments/1')
46 assert_recognizes({:controller => 'attachments', :action => 'show', :id => '1'}, '/attachments/show/1')
46 assert_recognizes({:controller => 'attachments', :action => 'show', :id => '1'}, '/attachments/show/1')
47 assert_recognizes({:controller => 'attachments', :action => 'show', :id => '1', :filename => 'filename.ext'}, '/attachments/1/filename.ext')
47 assert_recognizes({:controller => 'attachments', :action => 'show', :id => '1', :filename => 'filename.ext'}, '/attachments/1/filename.ext')
48 assert_recognizes({:controller => 'attachments', :action => 'download', :id => '1'}, '/attachments/download/1')
48 assert_recognizes({:controller => 'attachments', :action => 'download', :id => '1'}, '/attachments/download/1')
49 assert_recognizes({:controller => 'attachments', :action => 'download', :id => '1', :filename => 'filename.ext'},'/attachments/download/1/filename.ext')
49 assert_recognizes({:controller => 'attachments', :action => 'download', :id => '1', :filename => 'filename.ext'},'/attachments/download/1/filename.ext')
50 end
50 end
51
51
52 def test_show_diff
52 def test_show_diff
53 get :show, :id => 5
53 get :show, :id => 5
54 assert_response :success
54 assert_response :success
55 assert_template 'diff'
55 assert_template 'diff'
56 assert_equal 'text/html', @response.content_type
56 assert_equal 'text/html', @response.content_type
57 end
57 end
58
58
59 def test_show_text_file
59 def test_show_text_file
60 get :show, :id => 4
60 get :show, :id => 4
61 assert_response :success
61 assert_response :success
62 assert_template 'file'
62 assert_template 'file'
63 assert_equal 'text/html', @response.content_type
63 assert_equal 'text/html', @response.content_type
64 end
64 end
65
65
66 def test_show_text_file_should_send_if_too_big
66 def test_show_text_file_should_send_if_too_big
67 Setting.file_max_size_displayed = 512
67 Setting.file_max_size_displayed = 512
68 Attachment.find(4).update_attribute :filesize, 754.kilobyte
68 Attachment.find(4).update_attribute :filesize, 754.kilobyte
69
69
70 get :show, :id => 4
70 get :show, :id => 4
71 assert_response :success
71 assert_response :success
72 assert_equal 'application/x-ruby', @response.content_type
72 assert_equal 'application/x-ruby', @response.content_type
73 end
73 end
74
74
75 def test_show_other
75 def test_show_other
76 get :show, :id => 6
76 get :show, :id => 6
77 assert_response :success
77 assert_response :success
78 assert_equal 'application/octet-stream', @response.content_type
78 assert_equal 'application/octet-stream', @response.content_type
79 end
79 end
80
80
81 def test_download_text_file
81 def test_download_text_file
82 get :download, :id => 4
82 get :download, :id => 4
83 assert_response :success
83 assert_response :success
84 assert_equal 'application/x-ruby', @response.content_type
84 assert_equal 'application/x-ruby', @response.content_type
85 end
85 end
86
86
87 def test_download_should_assign_content_type_if_blank
88 Attachment.find(4).update_attribute(:content_type, '')
89
90 get :download, :id => 4
91 assert_response :success
92 assert_equal 'text/x-ruby', @response.content_type
93 end
94
87 def test_download_missing_file
95 def test_download_missing_file
88 get :download, :id => 2
96 get :download, :id => 2
89 assert_response 404
97 assert_response 404
90 end
98 end
91
99
92 def test_anonymous_on_private_private
100 def test_anonymous_on_private_private
93 get :download, :id => 7
101 get :download, :id => 7
94 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdownload%2F7'
102 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdownload%2F7'
95 end
103 end
96
104
97 def test_destroy_issue_attachment
105 def test_destroy_issue_attachment
98 issue = Issue.find(3)
106 issue = Issue.find(3)
99 @request.session[:user_id] = 2
107 @request.session[:user_id] = 2
100
108
101 assert_difference 'issue.attachments.count', -1 do
109 assert_difference 'issue.attachments.count', -1 do
102 post :destroy, :id => 1
110 post :destroy, :id => 1
103 end
111 end
104 # no referrer
112 # no referrer
105 assert_redirected_to 'projects/ecookbook'
113 assert_redirected_to 'projects/ecookbook'
106 assert_nil Attachment.find_by_id(1)
114 assert_nil Attachment.find_by_id(1)
107 j = issue.journals.find(:first, :order => 'created_on DESC')
115 j = issue.journals.find(:first, :order => 'created_on DESC')
108 assert_equal 'attachment', j.details.first.property
116 assert_equal 'attachment', j.details.first.property
109 assert_equal '1', j.details.first.prop_key
117 assert_equal '1', j.details.first.prop_key
110 assert_equal 'error281.txt', j.details.first.old_value
118 assert_equal 'error281.txt', j.details.first.old_value
111 end
119 end
112
120
113 def test_destroy_wiki_page_attachment
121 def test_destroy_wiki_page_attachment
114 @request.session[:user_id] = 2
122 @request.session[:user_id] = 2
115 assert_difference 'Attachment.count', -1 do
123 assert_difference 'Attachment.count', -1 do
116 post :destroy, :id => 3
124 post :destroy, :id => 3
117 assert_response 302
125 assert_response 302
118 end
126 end
119 end
127 end
120
128
121 def test_destroy_project_attachment
129 def test_destroy_project_attachment
122 @request.session[:user_id] = 2
130 @request.session[:user_id] = 2
123 assert_difference 'Attachment.count', -1 do
131 assert_difference 'Attachment.count', -1 do
124 post :destroy, :id => 8
132 post :destroy, :id => 8
125 assert_response 302
133 assert_response 302
126 end
134 end
127 end
135 end
128
136
129 def test_destroy_version_attachment
137 def test_destroy_version_attachment
130 @request.session[:user_id] = 2
138 @request.session[:user_id] = 2
131 assert_difference 'Attachment.count', -1 do
139 assert_difference 'Attachment.count', -1 do
132 post :destroy, :id => 9
140 post :destroy, :id => 9
133 assert_response 302
141 assert_response 302
134 end
142 end
135 end
143 end
136
144
137 def test_destroy_without_permission
145 def test_destroy_without_permission
138 post :destroy, :id => 3
146 post :destroy, :id => 3
139 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdestroy%2F3'
147 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdestroy%2F3'
140 assert Attachment.find_by_id(3)
148 assert Attachment.find_by_id(3)
141 end
149 end
142 end
150 end
@@ -1,48 +1,56
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # redMine - project management software
3 # redMine - project management software
4 # Copyright (C) 2006-2007 Jean-Philippe Lang
4 # Copyright (C) 2006-2007 Jean-Philippe Lang
5 #
5 #
6 # This program is free software; you can redistribute it and/or
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
9 # of the License, or (at your option) any later version.
10 #
10 #
11 # This program is distributed in the hope that it will be useful,
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
14 # GNU General Public License for more details.
15 #
15 #
16 # You should have received a copy of the GNU General Public License
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19
19
20 require File.dirname(__FILE__) + '/../test_helper'
20 require File.dirname(__FILE__) + '/../test_helper'
21
21
22 class AttachmentTest < ActiveSupport::TestCase
22 class AttachmentTest < ActiveSupport::TestCase
23 fixtures :issues, :users
23 fixtures :issues, :users
24
24
25 def setup
25 def setup
26 end
26 end
27
27
28 def test_create
28 def test_create
29 a = Attachment.new(:container => Issue.find(1),
29 a = Attachment.new(:container => Issue.find(1),
30 :file => uploaded_test_file("testfile.txt", "text/plain"),
30 :file => uploaded_test_file("testfile.txt", "text/plain"),
31 :author => User.find(1))
31 :author => User.find(1))
32 assert a.save
32 assert a.save
33 assert_equal 'testfile.txt', a.filename
33 assert_equal 'testfile.txt', a.filename
34 assert_equal 59, a.filesize
34 assert_equal 59, a.filesize
35 assert_equal 'text/plain', a.content_type
35 assert_equal 'text/plain', a.content_type
36 assert_equal 0, a.downloads
36 assert_equal 0, a.downloads
37 assert_equal Digest::MD5.hexdigest(uploaded_test_file("testfile.txt", "text/plain").read), a.digest
37 assert_equal Digest::MD5.hexdigest(uploaded_test_file("testfile.txt", "text/plain").read), a.digest
38 assert File.exist?(a.diskfile)
38 assert File.exist?(a.diskfile)
39 end
39 end
40
40
41 def test_create_should_auto_assign_content_type
42 a = Attachment.new(:container => Issue.find(1),
43 :file => uploaded_test_file("testfile.txt", ""),
44 :author => User.find(1))
45 assert a.save
46 assert_equal 'text/plain', a.content_type
47 end
48
41 def test_diskfilename
49 def test_diskfilename
42 assert Attachment.disk_filename("test_file.txt") =~ /^\d{12}_test_file.txt$/
50 assert Attachment.disk_filename("test_file.txt") =~ /^\d{12}_test_file.txt$/
43 assert_equal 'test_file.txt', Attachment.disk_filename("test_file.txt")[13..-1]
51 assert_equal 'test_file.txt', Attachment.disk_filename("test_file.txt")[13..-1]
44 assert_equal '770c509475505f37c2b8fb6030434d6b.txt', Attachment.disk_filename("test_accentué.txt")[13..-1]
52 assert_equal '770c509475505f37c2b8fb6030434d6b.txt', Attachment.disk_filename("test_accentué.txt")[13..-1]
45 assert_equal 'f8139524ebb8f32e51976982cd20a85d', Attachment.disk_filename("test_accentué")[13..-1]
53 assert_equal 'f8139524ebb8f32e51976982cd20a85d', Attachment.disk_filename("test_accentué")[13..-1]
46 assert_equal 'cbb5b0f30978ba03731d61f9f6d10011', Attachment.disk_filename("test_accentué.ça")[13..-1]
54 assert_equal 'cbb5b0f30978ba03731d61f9f6d10011', Attachment.disk_filename("test_accentué.ça")[13..-1]
47 end
55 end
48 end
56 end
General Comments 0
You need to be logged in to leave comments. Login now