Redmine 3.4.4
This commit is contained in:
commit
64924a6376
2112 changed files with 259028 additions and 0 deletions
137
lib/redmine/access_control.rb
Normal file
137
lib/redmine/access_control.rb
Normal file
|
@ -0,0 +1,137 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module AccessControl
|
||||
|
||||
class << self
|
||||
def map
|
||||
mapper = Mapper.new
|
||||
yield mapper
|
||||
@permissions ||= []
|
||||
@permissions += mapper.mapped_permissions
|
||||
end
|
||||
|
||||
def permissions
|
||||
@permissions
|
||||
end
|
||||
|
||||
# Returns the permission of given name or nil if it wasn't found
|
||||
# Argument should be a symbol
|
||||
def permission(name)
|
||||
permissions.detect {|p| p.name == name}
|
||||
end
|
||||
|
||||
# Returns the actions that are allowed by the permission of given name
|
||||
def allowed_actions(permission_name)
|
||||
perm = permission(permission_name)
|
||||
perm ? perm.actions : []
|
||||
end
|
||||
|
||||
def public_permissions
|
||||
@public_permissions ||= @permissions.select {|p| p.public?}
|
||||
end
|
||||
|
||||
def members_only_permissions
|
||||
@members_only_permissions ||= @permissions.select {|p| p.require_member?}
|
||||
end
|
||||
|
||||
def loggedin_only_permissions
|
||||
@loggedin_only_permissions ||= @permissions.select {|p| p.require_loggedin?}
|
||||
end
|
||||
|
||||
def read_action?(action)
|
||||
if action.is_a?(Symbol)
|
||||
perm = permission(action)
|
||||
!perm.nil? && perm.read?
|
||||
elsif action.is_a?(Hash)
|
||||
s = "#{action[:controller]}/#{action[:action]}"
|
||||
permissions.detect {|p| p.actions.include?(s) && p.read?}.present?
|
||||
else
|
||||
raise ArgumentError.new("Symbol or a Hash expected, #{action.class.name} given: #{action}")
|
||||
end
|
||||
end
|
||||
|
||||
def available_project_modules
|
||||
@available_project_modules ||= @permissions.collect(&:project_module).uniq.compact
|
||||
end
|
||||
|
||||
def modules_permissions(modules)
|
||||
@permissions.select {|p| p.project_module.nil? || modules.include?(p.project_module.to_s)}
|
||||
end
|
||||
end
|
||||
|
||||
class Mapper
|
||||
def initialize
|
||||
@project_module = nil
|
||||
end
|
||||
|
||||
def permission(name, hash, options={})
|
||||
@permissions ||= []
|
||||
options.merge!(:project_module => @project_module)
|
||||
@permissions << Permission.new(name, hash, options)
|
||||
end
|
||||
|
||||
def project_module(name, options={})
|
||||
@project_module = name
|
||||
yield self
|
||||
@project_module = nil
|
||||
end
|
||||
|
||||
def mapped_permissions
|
||||
@permissions
|
||||
end
|
||||
end
|
||||
|
||||
class Permission
|
||||
attr_reader :name, :actions, :project_module
|
||||
|
||||
def initialize(name, hash, options)
|
||||
@name = name
|
||||
@actions = []
|
||||
@public = options[:public] || false
|
||||
@require = options[:require]
|
||||
@read = options[:read] || false
|
||||
@project_module = options[:project_module]
|
||||
hash.each do |controller, actions|
|
||||
if actions.is_a? Array
|
||||
@actions << actions.collect {|action| "#{controller}/#{action}"}
|
||||
else
|
||||
@actions << "#{controller}/#{actions}"
|
||||
end
|
||||
end
|
||||
@actions.flatten!
|
||||
end
|
||||
|
||||
def public?
|
||||
@public
|
||||
end
|
||||
|
||||
def require_member?
|
||||
@require && @require == :member
|
||||
end
|
||||
|
||||
def require_loggedin?
|
||||
@require && (@require == :member || @require == :loggedin)
|
||||
end
|
||||
|
||||
def read?
|
||||
@read
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
33
lib/redmine/access_keys.rb
Normal file
33
lib/redmine/access_keys.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module AccessKeys
|
||||
ACCESSKEYS = {:edit => 'e',
|
||||
:preview => 'r',
|
||||
:quick_search => 'f',
|
||||
:search => '4',
|
||||
:new_issue => '7',
|
||||
:previous => 'p',
|
||||
:next => 'n'
|
||||
}.freeze unless const_defined?(:ACCESSKEYS)
|
||||
|
||||
def self.key_for(action)
|
||||
ACCESSKEYS[action]
|
||||
end
|
||||
end
|
||||
end
|
52
lib/redmine/activity.rb
Normal file
52
lib/redmine/activity.rb
Normal file
|
@ -0,0 +1,52 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Activity
|
||||
|
||||
mattr_accessor :available_event_types, :default_event_types, :providers
|
||||
|
||||
@@available_event_types = []
|
||||
@@default_event_types = []
|
||||
@@providers = Hash.new {|h,k| h[k]=[] }
|
||||
|
||||
class << self
|
||||
def map(&block)
|
||||
yield self
|
||||
end
|
||||
|
||||
# Registers an activity provider
|
||||
def register(event_type, options={})
|
||||
options.assert_valid_keys(:class_name, :default)
|
||||
|
||||
event_type = event_type.to_s
|
||||
providers = options[:class_name] || event_type.classify
|
||||
providers = ([] << providers) unless providers.is_a?(Array)
|
||||
|
||||
@@available_event_types << event_type unless @@available_event_types.include?(event_type)
|
||||
@@default_event_types << event_type unless options[:default] == false
|
||||
@@providers[event_type] += providers
|
||||
end
|
||||
|
||||
def delete(event_type)
|
||||
@@available_event_types.delete event_type
|
||||
@@default_event_types.delete event_type
|
||||
@@providers.delete(event_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
110
lib/redmine/activity/fetcher.rb
Normal file
110
lib/redmine/activity/fetcher.rb
Normal file
|
@ -0,0 +1,110 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Activity
|
||||
# Class used to retrieve activity events
|
||||
class Fetcher
|
||||
attr_reader :user, :project, :scope
|
||||
|
||||
def initialize(user, options={})
|
||||
options.assert_valid_keys(:project, :with_subprojects, :author)
|
||||
@user = user
|
||||
@project = options[:project]
|
||||
@options = options
|
||||
|
||||
@scope = event_types
|
||||
end
|
||||
|
||||
# Returns an array of available event types
|
||||
def event_types
|
||||
return @event_types unless @event_types.nil?
|
||||
|
||||
@event_types = Redmine::Activity.available_event_types
|
||||
if @project
|
||||
projects = @project.self_and_descendants
|
||||
@event_types = @event_types.select do |event_type|
|
||||
keep = false
|
||||
constantized_providers(event_type).each do |provider|
|
||||
options = provider.activity_provider_options[event_type]
|
||||
permission = options[:permission]
|
||||
unless options.key?(:permission)
|
||||
permission ||= "view_#{event_type}".to_sym
|
||||
end
|
||||
if permission
|
||||
keep |= projects.any? {|p| @user.allowed_to?(permission, p)}
|
||||
else
|
||||
keep = true
|
||||
end
|
||||
end
|
||||
keep
|
||||
end
|
||||
end
|
||||
@event_types
|
||||
end
|
||||
|
||||
# Yields to filter the activity scope
|
||||
def scope_select(&block)
|
||||
@scope = @scope.select {|t| yield t }
|
||||
end
|
||||
|
||||
# Sets the scope
|
||||
# Argument can be :all, :default or an array of event types
|
||||
def scope=(s)
|
||||
case s
|
||||
when :all
|
||||
@scope = event_types
|
||||
when :default
|
||||
default_scope!
|
||||
else
|
||||
@scope = s & event_types
|
||||
end
|
||||
end
|
||||
|
||||
# Resets the scope to the default scope
|
||||
def default_scope!
|
||||
@scope = Redmine::Activity.default_event_types
|
||||
end
|
||||
|
||||
# Returns an array of events for the given date range
|
||||
# sorted in reverse chronological order
|
||||
def events(from = nil, to = nil, options={})
|
||||
e = []
|
||||
@options[:limit] = options[:limit]
|
||||
|
||||
@scope.each do |event_type|
|
||||
constantized_providers(event_type).each do |provider|
|
||||
e += provider.find_events(event_type, @user, from, to, @options)
|
||||
end
|
||||
end
|
||||
|
||||
e.sort! {|a,b| b.event_datetime <=> a.event_datetime}
|
||||
|
||||
if options[:limit]
|
||||
e = e.slice(0, options[:limit])
|
||||
end
|
||||
e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def constantized_providers(event_type)
|
||||
Redmine::Activity.providers[event_type].map(&:constantize)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
118
lib/redmine/acts/positioned.rb
Normal file
118
lib/redmine/acts/positioned.rb
Normal file
|
@ -0,0 +1,118 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Acts
|
||||
module Positioned
|
||||
def self.included(base)
|
||||
base.extend ClassMethods
|
||||
end
|
||||
|
||||
# This extension provides the capabilities for reordering objects in a list.
|
||||
# The class needs to have a +position+ column defined as an integer on the
|
||||
# mapped database table.
|
||||
module ClassMethods
|
||||
# Configuration options are:
|
||||
#
|
||||
# * +scope+ - restricts what is to be considered a list. Must be a symbol
|
||||
# or an array of symbols
|
||||
def acts_as_positioned(options = {})
|
||||
class_attribute :positioned_options
|
||||
self.positioned_options = {:scope => Array(options[:scope])}
|
||||
|
||||
send :include, Redmine::Acts::Positioned::InstanceMethods
|
||||
|
||||
before_save :set_default_position
|
||||
after_save :update_position
|
||||
after_destroy :remove_position
|
||||
end
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def self.included(base)
|
||||
base.extend ClassMethods
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def position_scope
|
||||
build_position_scope {|c| send(c)}
|
||||
end
|
||||
|
||||
def position_scope_was
|
||||
build_position_scope {|c| send("#{c}_was")}
|
||||
end
|
||||
|
||||
def build_position_scope
|
||||
condition_hash = self.class.positioned_options[:scope].inject({}) do |h, column|
|
||||
h[column] = yield(column)
|
||||
h
|
||||
end
|
||||
self.class.where(condition_hash)
|
||||
end
|
||||
|
||||
def set_default_position
|
||||
if position.nil?
|
||||
self.position = position_scope.maximum(:position).to_i + (new_record? ? 1 : 0)
|
||||
end
|
||||
end
|
||||
|
||||
def update_position
|
||||
if !new_record? && position_scope_changed?
|
||||
remove_position
|
||||
insert_position
|
||||
elsif position_changed?
|
||||
if position_was.nil?
|
||||
insert_position
|
||||
else
|
||||
shift_positions
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def insert_position
|
||||
position_scope.where("position >= ? AND id <> ?", position, id).update_all("position = position + 1")
|
||||
end
|
||||
|
||||
def remove_position
|
||||
position_scope_was.where("position >= ? AND id <> ?", position_was, id).update_all("position = position - 1")
|
||||
end
|
||||
|
||||
def position_scope_changed?
|
||||
(changed & self.class.positioned_options[:scope].map(&:to_s)).any?
|
||||
end
|
||||
|
||||
def shift_positions
|
||||
offset = position_was <=> position
|
||||
min, max = [position, position_was].sort
|
||||
r = position_scope.where("id <> ? AND position BETWEEN ? AND ?", id, min, max).update_all("position = position + #{offset}")
|
||||
if r != max - min
|
||||
reset_positions_in_list
|
||||
end
|
||||
end
|
||||
|
||||
def reset_positions_in_list
|
||||
position_scope.reorder(:position, :id).pluck(:id).each_with_index do |record_id, p|
|
||||
self.class.where(:id => record_id).update_all(:position => p+1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ActiveRecord::Base.send :include, Redmine::Acts::Positioned
|
103
lib/redmine/ciphering.rb
Normal file
103
lib/redmine/ciphering.rb
Normal file
|
@ -0,0 +1,103 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Ciphering
|
||||
def self.included(base)
|
||||
base.extend ClassMethods
|
||||
end
|
||||
|
||||
class << self
|
||||
def encrypt_text(text)
|
||||
if cipher_key.blank? || text.blank?
|
||||
text
|
||||
else
|
||||
c = OpenSSL::Cipher.new("aes-256-cbc")
|
||||
iv = c.random_iv
|
||||
c.encrypt
|
||||
c.key = cipher_key
|
||||
c.iv = iv
|
||||
e = c.update(text.to_s)
|
||||
e << c.final
|
||||
"aes-256-cbc:" + [e, iv].map {|v| Base64.encode64(v).strip}.join('--')
|
||||
end
|
||||
end
|
||||
|
||||
def decrypt_text(text)
|
||||
if text && match = text.match(/\Aaes-256-cbc:(.+)\Z/)
|
||||
if cipher_key.blank?
|
||||
logger.error "Attempt to decrypt a ciphered text with no cipher key configured in config/configuration.yml" if logger
|
||||
return text
|
||||
end
|
||||
text = match[1]
|
||||
c = OpenSSL::Cipher.new("aes-256-cbc")
|
||||
e, iv = text.split("--").map {|s| Base64.decode64(s)}
|
||||
c.decrypt
|
||||
c.key = cipher_key
|
||||
c.iv = iv
|
||||
d = c.update(e)
|
||||
d << c.final
|
||||
else
|
||||
text
|
||||
end
|
||||
end
|
||||
|
||||
def cipher_key
|
||||
key = Redmine::Configuration['database_cipher_key'].to_s
|
||||
key.blank? ? nil : Digest::SHA256.hexdigest(key)[0..31]
|
||||
end
|
||||
|
||||
def logger
|
||||
Rails.logger
|
||||
end
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def encrypt_all(attribute)
|
||||
transaction do
|
||||
all.each do |object|
|
||||
clear = object.send(attribute)
|
||||
object.send "#{attribute}=", clear
|
||||
raise(ActiveRecord::Rollback) unless object.save(:validation => false)
|
||||
end
|
||||
end ? true : false
|
||||
end
|
||||
|
||||
def decrypt_all(attribute)
|
||||
transaction do
|
||||
all.each do |object|
|
||||
clear = object.send(attribute)
|
||||
object.send :write_attribute, attribute, clear
|
||||
raise(ActiveRecord::Rollback) unless object.save(:validation => false)
|
||||
end
|
||||
end ? true : false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns the value of the given ciphered attribute
|
||||
def read_ciphered_attribute(attribute)
|
||||
Redmine::Ciphering.decrypt_text(read_attribute(attribute))
|
||||
end
|
||||
|
||||
# Sets the value of the given ciphered attribute
|
||||
def write_ciphered_attribute(attribute, value)
|
||||
write_attribute(attribute, Redmine::Ciphering.encrypt_text(value))
|
||||
end
|
||||
end
|
||||
end
|
68
lib/redmine/codeset_util.rb
Normal file
68
lib/redmine/codeset_util.rb
Normal file
|
@ -0,0 +1,68 @@
|
|||
|
||||
module Redmine
|
||||
module CodesetUtil
|
||||
|
||||
def self.replace_invalid_utf8(str)
|
||||
return str if str.nil?
|
||||
str.force_encoding('UTF-8')
|
||||
if ! str.valid_encoding?
|
||||
str = str.encode("UTF-16LE", :invalid => :replace,
|
||||
:undef => :replace, :replace => '?').encode("UTF-8")
|
||||
end
|
||||
str
|
||||
end
|
||||
|
||||
def self.to_utf8(str, encoding)
|
||||
return str if str.nil?
|
||||
str.force_encoding("ASCII-8BIT")
|
||||
if str.empty?
|
||||
str.force_encoding("UTF-8")
|
||||
return str
|
||||
end
|
||||
enc = encoding.blank? ? "UTF-8" : encoding
|
||||
if enc.upcase != "UTF-8"
|
||||
str.force_encoding(enc)
|
||||
str = str.encode("UTF-8", :invalid => :replace,
|
||||
:undef => :replace, :replace => '?')
|
||||
else
|
||||
str = replace_invalid_utf8(str)
|
||||
end
|
||||
str
|
||||
end
|
||||
|
||||
def self.to_utf8_by_setting(str)
|
||||
return str if str.nil?
|
||||
self.to_utf8_by_setting_internal(str).force_encoding('UTF-8')
|
||||
end
|
||||
|
||||
def self.to_utf8_by_setting_internal(str)
|
||||
return str if str.nil?
|
||||
str.force_encoding('ASCII-8BIT')
|
||||
return str if str.empty?
|
||||
return str if /\A[\r\n\t\x20-\x7e]*\Z/n.match(str) # for us-ascii
|
||||
str.force_encoding('UTF-8')
|
||||
encodings = Setting.repositories_encodings.split(',').collect(&:strip)
|
||||
encodings.each do |encoding|
|
||||
begin
|
||||
str.force_encoding(encoding)
|
||||
utf8 = str.encode('UTF-8')
|
||||
return utf8 if utf8.valid_encoding?
|
||||
rescue
|
||||
# do nothing here and try the next encoding
|
||||
end
|
||||
end
|
||||
self.replace_invalid_utf8(str).force_encoding('UTF-8')
|
||||
end
|
||||
|
||||
def self.from_utf8(str, encoding)
|
||||
str ||= ''
|
||||
str.force_encoding('UTF-8')
|
||||
if encoding.upcase != 'UTF-8'
|
||||
str = str.encode(encoding, :invalid => :replace,
|
||||
:undef => :replace, :replace => '?')
|
||||
else
|
||||
str = self.replace_invalid_utf8(str)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
128
lib/redmine/configuration.rb
Normal file
128
lib/redmine/configuration.rb
Normal file
|
@ -0,0 +1,128 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Configuration
|
||||
|
||||
# Configuration default values
|
||||
@defaults = {
|
||||
'email_delivery' => nil,
|
||||
'max_concurrent_ajax_uploads' => 2
|
||||
}
|
||||
|
||||
@config = nil
|
||||
|
||||
class << self
|
||||
# Loads the Redmine configuration file
|
||||
# Valid options:
|
||||
# * <tt>:file</tt>: the configuration file to load (default: config/configuration.yml)
|
||||
# * <tt>:env</tt>: the environment to load the configuration for (default: Rails.env)
|
||||
def load(options={})
|
||||
filename = options[:file] || File.join(Rails.root, 'config', 'configuration.yml')
|
||||
env = options[:env] || Rails.env
|
||||
|
||||
@config = @defaults.dup
|
||||
|
||||
load_deprecated_email_configuration(env)
|
||||
if File.file?(filename)
|
||||
@config.merge!(load_from_yaml(filename, env))
|
||||
end
|
||||
|
||||
# Compatibility mode for those who copy email.yml over configuration.yml
|
||||
%w(delivery_method smtp_settings sendmail_settings).each do |key|
|
||||
if value = @config.delete(key)
|
||||
@config['email_delivery'] ||= {}
|
||||
@config['email_delivery'][key] = value
|
||||
end
|
||||
end
|
||||
|
||||
if @config['email_delivery']
|
||||
ActionMailer::Base.perform_deliveries = true
|
||||
@config['email_delivery'].each do |k, v|
|
||||
v.symbolize_keys! if v.respond_to?(:symbolize_keys!)
|
||||
ActionMailer::Base.send("#{k}=", v)
|
||||
end
|
||||
end
|
||||
|
||||
check_regular_expressions
|
||||
@config
|
||||
end
|
||||
|
||||
# Returns a configuration setting
|
||||
def [](name)
|
||||
load unless @config
|
||||
@config[name]
|
||||
end
|
||||
|
||||
# Yields a block with the specified hash configuration settings
|
||||
def with(settings)
|
||||
settings.stringify_keys!
|
||||
load unless @config
|
||||
was = settings.keys.inject({}) {|h,v| h[v] = @config[v]; h}
|
||||
@config.merge! settings
|
||||
yield if block_given?
|
||||
@config.merge! was
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_from_yaml(filename, env)
|
||||
yaml = nil
|
||||
begin
|
||||
yaml = YAML::load(ERB.new(File.read(filename)).result)
|
||||
rescue ArgumentError
|
||||
abort "Your Redmine configuration file located at #{filename} is not a valid YAML file and could not be loaded."
|
||||
rescue SyntaxError => e
|
||||
abort "A syntax error occurred when parsing your Redmine configuration file located at #{filename} with ERB:\n#{e.message}"
|
||||
end
|
||||
conf = {}
|
||||
if yaml.is_a?(Hash)
|
||||
if yaml['default']
|
||||
conf.merge!(yaml['default'])
|
||||
end
|
||||
if yaml[env]
|
||||
conf.merge!(yaml[env])
|
||||
end
|
||||
else
|
||||
abort "Your Redmine configuration file located at #{filename} is not a valid Redmine configuration file."
|
||||
end
|
||||
conf
|
||||
end
|
||||
|
||||
def load_deprecated_email_configuration(env)
|
||||
deprecated_email_conf = File.join(Rails.root, 'config', 'email.yml')
|
||||
if File.file?(deprecated_email_conf)
|
||||
warn "Storing outgoing emails configuration in config/email.yml is deprecated. You should now store it in config/configuration.yml using the email_delivery setting."
|
||||
@config.merge!({'email_delivery' => load_from_yaml(deprecated_email_conf, env)})
|
||||
end
|
||||
end
|
||||
|
||||
# Checks the validness of regular expressions set for repository paths at startup
|
||||
def check_regular_expressions
|
||||
@config.each do |name, value|
|
||||
if value.present? && name =~ /^scm_.+_path_regexp$/
|
||||
begin
|
||||
Regexp.new value.to_s.strip
|
||||
rescue => e
|
||||
abort "Invalid regular expression set as #{name} setting in your Redmine configuration file:\n#{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
1
lib/redmine/core_ext.rb
Normal file
1
lib/redmine/core_ext.rb
Normal file
|
@ -0,0 +1 @@
|
|||
Dir[File.dirname(__FILE__) + "/core_ext/*.rb"].each { |file| require(file) }
|
27
lib/redmine/core_ext/active_record.rb
Normal file
27
lib/redmine/core_ext/active_record.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class DateValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
before_type_cast = record.attributes_before_type_cast[attribute.to_s]
|
||||
if before_type_cast.is_a?(String) && before_type_cast.present?
|
||||
unless before_type_cast =~ /\A\d{4}-\d{2}-\d{2}( 00:00:00)?\z/ && value
|
||||
record.errors.add attribute, :not_a_date
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
5
lib/redmine/core_ext/date.rb
Normal file
5
lib/redmine/core_ext/date.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
require File.dirname(__FILE__) + '/date/calculations'
|
||||
|
||||
class Date #:nodoc:
|
||||
include Redmine::CoreExtensions::Date::Calculations
|
||||
end
|
35
lib/redmine/core_ext/date/calculations.rb
Normal file
35
lib/redmine/core_ext/date/calculations.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine #:nodoc:
|
||||
module CoreExtensions #:nodoc:
|
||||
module Date #:nodoc:
|
||||
# Custom date calculations
|
||||
module Calculations
|
||||
# Returns difference with specified date in months
|
||||
def months_ago(date = self.class.today)
|
||||
(date.year - self.year)*12 + (date.month - self.month)
|
||||
end
|
||||
|
||||
# Returns difference with specified date in weeks
|
||||
def weeks_ago(date = self.class.today)
|
||||
(date.year - self.year)*52 + (date.cweek - self.cweek)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
7
lib/redmine/core_ext/string.rb
Normal file
7
lib/redmine/core_ext/string.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require File.dirname(__FILE__) + '/string/conversions'
|
||||
require File.dirname(__FILE__) + '/string/inflections'
|
||||
|
||||
class String #:nodoc:
|
||||
include Redmine::CoreExtensions::String::Conversions
|
||||
include Redmine::CoreExtensions::String::Inflections
|
||||
end
|
42
lib/redmine/core_ext/string/conversions.rb
Normal file
42
lib/redmine/core_ext/string/conversions.rb
Normal file
|
@ -0,0 +1,42 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine #:nodoc:
|
||||
module CoreExtensions #:nodoc:
|
||||
module String #:nodoc:
|
||||
# Custom string conversions
|
||||
module Conversions
|
||||
# Parses hours format and returns a float
|
||||
def to_hours
|
||||
s = self.dup
|
||||
s.strip!
|
||||
if s =~ %r{^(\d+([.,]\d+)?)h?$}
|
||||
s = $1
|
||||
else
|
||||
# 2:30 => 2.5
|
||||
s.gsub!(%r{^(\d+):(\d+)$}) { $1.to_i + $2.to_i / 60.0 }
|
||||
# 2h30, 2h, 30m => 2.5, 2, 0.5
|
||||
s.gsub!(%r{^((\d+)\s*(h|hours?))?\s*((\d+)\s*(m|min)?)?$}i) { |m| ($1 || $4) ? ($2.to_i + $5.to_i / 60.0) : m[0] }
|
||||
end
|
||||
# 2,5 => 2.5
|
||||
s.gsub!(',', '.')
|
||||
begin; Kernel.Float(s); rescue; nil; end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
29
lib/redmine/core_ext/string/inflections.rb
Normal file
29
lib/redmine/core_ext/string/inflections.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine #:nodoc:
|
||||
module CoreExtensions #:nodoc:
|
||||
module String #:nodoc:
|
||||
# Custom string inflections
|
||||
module Inflections
|
||||
def with_leading_slash
|
||||
starts_with?('/') ? self : "/#{ self }"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
73
lib/redmine/database.rb
Normal file
73
lib/redmine/database.rb
Normal file
|
@ -0,0 +1,73 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
# Helper module to get information about the Redmine database
|
||||
module Database
|
||||
class << self
|
||||
# Returns true if the database is PostgreSQL
|
||||
def postgresql?
|
||||
(ActiveRecord::Base.connection.adapter_name =~ /postgresql/i).present?
|
||||
end
|
||||
|
||||
# Returns the PostgreSQL version or nil if another DBMS is used
|
||||
def postgresql_version
|
||||
postgresql? ? ActiveRecord::Base.connection.send(:postgresql_version) : nil
|
||||
end
|
||||
|
||||
# Returns true if the database is a PostgreSQL >=9.0 database with the unaccent extension installed
|
||||
def postgresql_unaccent?
|
||||
if postgresql?
|
||||
return @postgresql_unaccent unless @postgresql_unaccent.nil?
|
||||
begin
|
||||
sql = "SELECT name FROM pg_available_extensions WHERE installed_version IS NOT NULL and name = 'unaccent'"
|
||||
@postgresql_unaccent = postgresql_version >= 90000 && ActiveRecord::Base.connection.select_value(sql).present?
|
||||
rescue
|
||||
false
|
||||
end
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the database is MySQL
|
||||
def mysql?
|
||||
(ActiveRecord::Base.connection.adapter_name =~ /mysql/i).present?
|
||||
end
|
||||
|
||||
# Returns a SQL statement for case/accent (if possible) insensitive match
|
||||
def like(left, right, options={})
|
||||
neg = (options[:match] == false ? 'NOT ' : '')
|
||||
|
||||
if postgresql?
|
||||
if postgresql_unaccent?
|
||||
"unaccent(#{left}) #{neg}ILIKE unaccent(#{right})"
|
||||
else
|
||||
"#{left} #{neg}ILIKE #{right}"
|
||||
end
|
||||
else
|
||||
"#{left} #{neg}LIKE #{right}"
|
||||
end
|
||||
end
|
||||
|
||||
# Resets database information
|
||||
def reset
|
||||
@postgresql_unaccent = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
197
lib/redmine/default_data/loader.rb
Normal file
197
lib/redmine/default_data/loader.rb
Normal file
|
@ -0,0 +1,197 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module DefaultData
|
||||
class DataAlreadyLoaded < Exception; end
|
||||
|
||||
module Loader
|
||||
include Redmine::I18n
|
||||
|
||||
class << self
|
||||
# Returns true if no data is already loaded in the database
|
||||
# otherwise false
|
||||
def no_data?
|
||||
!Role.where(:builtin => 0).exists? &&
|
||||
!Tracker.exists? &&
|
||||
!IssueStatus.exists? &&
|
||||
!Enumeration.exists?
|
||||
end
|
||||
|
||||
# Loads the default data
|
||||
# Raises a RecordNotSaved exception if something goes wrong
|
||||
def load(lang=nil, options={})
|
||||
raise DataAlreadyLoaded.new("Some configuration data is already loaded.") unless no_data?
|
||||
set_language_if_valid(lang)
|
||||
workflow = !(options[:workflow] == false)
|
||||
|
||||
Role.transaction do
|
||||
# Roles
|
||||
manager = Role.create! :name => l(:default_role_manager),
|
||||
:issues_visibility => 'all',
|
||||
:users_visibility => 'all',
|
||||
:position => 1
|
||||
manager.permissions = manager.setable_permissions.collect {|p| p.name}
|
||||
manager.save!
|
||||
|
||||
developer = Role.create! :name => l(:default_role_developer),
|
||||
:position => 2,
|
||||
:permissions => [:manage_versions,
|
||||
:manage_categories,
|
||||
:view_issues,
|
||||
:add_issues,
|
||||
:edit_issues,
|
||||
:view_private_notes,
|
||||
:set_notes_private,
|
||||
:manage_issue_relations,
|
||||
:manage_subtasks,
|
||||
:add_issue_notes,
|
||||
:save_queries,
|
||||
:view_gantt,
|
||||
:view_calendar,
|
||||
:log_time,
|
||||
:view_time_entries,
|
||||
:view_news,
|
||||
:comment_news,
|
||||
:view_documents,
|
||||
:view_wiki_pages,
|
||||
:view_wiki_edits,
|
||||
:edit_wiki_pages,
|
||||
:delete_wiki_pages,
|
||||
:view_messages,
|
||||
:add_messages,
|
||||
:edit_own_messages,
|
||||
:view_files,
|
||||
:manage_files,
|
||||
:browse_repository,
|
||||
:view_changesets,
|
||||
:commit_access,
|
||||
:manage_related_issues]
|
||||
|
||||
reporter = Role.create! :name => l(:default_role_reporter),
|
||||
:position => 3,
|
||||
:permissions => [:view_issues,
|
||||
:add_issues,
|
||||
:add_issue_notes,
|
||||
:save_queries,
|
||||
:view_gantt,
|
||||
:view_calendar,
|
||||
:log_time,
|
||||
:view_time_entries,
|
||||
:view_news,
|
||||
:comment_news,
|
||||
:view_documents,
|
||||
:view_wiki_pages,
|
||||
:view_wiki_edits,
|
||||
:view_messages,
|
||||
:add_messages,
|
||||
:edit_own_messages,
|
||||
:view_files,
|
||||
:browse_repository,
|
||||
:view_changesets]
|
||||
|
||||
Role.non_member.update_attribute :permissions, [:view_issues,
|
||||
:add_issues,
|
||||
:add_issue_notes,
|
||||
:save_queries,
|
||||
:view_gantt,
|
||||
:view_calendar,
|
||||
:view_time_entries,
|
||||
:view_news,
|
||||
:comment_news,
|
||||
:view_documents,
|
||||
:view_wiki_pages,
|
||||
:view_wiki_edits,
|
||||
:view_messages,
|
||||
:add_messages,
|
||||
:view_files,
|
||||
:browse_repository,
|
||||
:view_changesets]
|
||||
|
||||
Role.anonymous.update_attribute :permissions, [:view_issues,
|
||||
:view_gantt,
|
||||
:view_calendar,
|
||||
:view_time_entries,
|
||||
:view_news,
|
||||
:view_documents,
|
||||
:view_wiki_pages,
|
||||
:view_wiki_edits,
|
||||
:view_messages,
|
||||
:view_files,
|
||||
:browse_repository,
|
||||
:view_changesets]
|
||||
|
||||
# Issue statuses
|
||||
new = IssueStatus.create!(:name => l(:default_issue_status_new), :is_closed => false, :position => 1)
|
||||
in_progress = IssueStatus.create!(:name => l(:default_issue_status_in_progress), :is_closed => false, :position => 2)
|
||||
resolved = IssueStatus.create!(:name => l(:default_issue_status_resolved), :is_closed => false, :position => 3)
|
||||
feedback = IssueStatus.create!(:name => l(:default_issue_status_feedback), :is_closed => false, :position => 4)
|
||||
closed = IssueStatus.create!(:name => l(:default_issue_status_closed), :is_closed => true, :position => 5)
|
||||
rejected = IssueStatus.create!(:name => l(:default_issue_status_rejected), :is_closed => true, :position => 6)
|
||||
|
||||
# Trackers
|
||||
Tracker.create!(:name => l(:default_tracker_bug), :default_status_id => new.id, :is_in_chlog => true, :is_in_roadmap => false, :position => 1)
|
||||
Tracker.create!(:name => l(:default_tracker_feature), :default_status_id => new.id, :is_in_chlog => true, :is_in_roadmap => true, :position => 2)
|
||||
Tracker.create!(:name => l(:default_tracker_support), :default_status_id => new.id, :is_in_chlog => false, :is_in_roadmap => false, :position => 3)
|
||||
|
||||
if workflow
|
||||
# Workflow
|
||||
Tracker.all.each { |t|
|
||||
IssueStatus.all.each { |os|
|
||||
IssueStatus.all.each { |ns|
|
||||
WorkflowTransition.create!(:tracker_id => t.id, :role_id => manager.id, :old_status_id => os.id, :new_status_id => ns.id) unless os == ns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Tracker.all.each { |t|
|
||||
[new, in_progress, resolved, feedback].each { |os|
|
||||
[in_progress, resolved, feedback, closed].each { |ns|
|
||||
WorkflowTransition.create!(:tracker_id => t.id, :role_id => developer.id, :old_status_id => os.id, :new_status_id => ns.id) unless os == ns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Tracker.all.each { |t|
|
||||
[new, in_progress, resolved, feedback].each { |os|
|
||||
[closed].each { |ns|
|
||||
WorkflowTransition.create!(:tracker_id => t.id, :role_id => reporter.id, :old_status_id => os.id, :new_status_id => ns.id) unless os == ns
|
||||
}
|
||||
}
|
||||
WorkflowTransition.create!(:tracker_id => t.id, :role_id => reporter.id, :old_status_id => resolved.id, :new_status_id => feedback.id)
|
||||
}
|
||||
end
|
||||
|
||||
# Enumerations
|
||||
IssuePriority.create!(:name => l(:default_priority_low), :position => 1)
|
||||
IssuePriority.create!(:name => l(:default_priority_normal), :position => 2, :is_default => true)
|
||||
IssuePriority.create!(:name => l(:default_priority_high), :position => 3)
|
||||
IssuePriority.create!(:name => l(:default_priority_urgent), :position => 4)
|
||||
IssuePriority.create!(:name => l(:default_priority_immediate), :position => 5)
|
||||
|
||||
DocumentCategory.create!(:name => l(:default_doc_category_user), :position => 1)
|
||||
DocumentCategory.create!(:name => l(:default_doc_category_tech), :position => 2)
|
||||
|
||||
TimeEntryActivity.create!(:name => l(:default_activity_design), :position => 1)
|
||||
TimeEntryActivity.create!(:name => l(:default_activity_development), :position => 2)
|
||||
end
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
65
lib/redmine/export/csv.rb
Normal file
65
lib/redmine/export/csv.rb
Normal file
|
@ -0,0 +1,65 @@
|
|||
# encoding: utf-8
|
||||
#
|
||||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'csv'
|
||||
|
||||
module Redmine
|
||||
module Export
|
||||
module CSV
|
||||
def self.generate(*args, &block)
|
||||
Base.generate(*args, &block)
|
||||
end
|
||||
|
||||
class Base < ::CSV
|
||||
include Redmine::I18n
|
||||
|
||||
class << self
|
||||
|
||||
def generate(&block)
|
||||
col_sep = l(:general_csv_separator)
|
||||
encoding = l(:general_csv_encoding)
|
||||
|
||||
str = ''.force_encoding(encoding)
|
||||
if encoding == 'UTF-8'
|
||||
# BOM
|
||||
str = "\xEF\xBB\xBF".force_encoding(encoding)
|
||||
end
|
||||
|
||||
super(str, :col_sep => col_sep, :encoding => encoding, &block)
|
||||
end
|
||||
end
|
||||
|
||||
def <<(row)
|
||||
row = row.map do |field|
|
||||
case field
|
||||
when String
|
||||
Redmine::CodesetUtil.from_utf8(field, self.encoding.name)
|
||||
when Float
|
||||
@decimal_separator ||= l(:general_csv_decimal_separator)
|
||||
("%.2f" % field).gsub('.', @decimal_separator)
|
||||
else
|
||||
field
|
||||
end
|
||||
end
|
||||
super row
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
156
lib/redmine/export/pdf.rb
Normal file
156
lib/redmine/export/pdf.rb
Normal file
|
@ -0,0 +1,156 @@
|
|||
# encoding: utf-8
|
||||
#
|
||||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'rbpdf'
|
||||
|
||||
module Redmine
|
||||
module Export
|
||||
module PDF
|
||||
class ITCPDF < RBPDF
|
||||
include Redmine::I18n
|
||||
attr_accessor :footer_date
|
||||
|
||||
def initialize(lang, orientation='P')
|
||||
@@k_path_cache = Rails.root.join('tmp', 'pdf')
|
||||
FileUtils.mkdir_p @@k_path_cache unless File::exist?(@@k_path_cache)
|
||||
set_language_if_valid lang
|
||||
super(orientation, 'mm', 'A4')
|
||||
set_print_header(false)
|
||||
set_rtl(l(:direction) == 'rtl')
|
||||
|
||||
@font_for_content = l(:general_pdf_fontname)
|
||||
@monospaced_font_for_content = l(:general_pdf_monospaced_fontname)
|
||||
@font_for_footer = l(:general_pdf_fontname)
|
||||
set_creator(Redmine::Info.app_name)
|
||||
set_font(@font_for_content)
|
||||
|
||||
set_header_font([@font_for_content, '', 10])
|
||||
set_footer_font([@font_for_content, '', 8])
|
||||
set_default_monospaced_font(@monospaced_font_for_content)
|
||||
set_display_mode('default', 'OneColumn')
|
||||
end
|
||||
|
||||
def SetFontStyle(style, size)
|
||||
set_font(@font_for_content, style, size)
|
||||
end
|
||||
|
||||
def SetFont(family, style='', size=0, fontfile='')
|
||||
# FreeSerif Bold Thai font has problem.
|
||||
style.delete!('B') if family.to_s.casecmp('freeserif') == 0
|
||||
# DejaVuSans Italic Arabic and Persian font has problem.
|
||||
style.delete!('I') if family.to_s.casecmp('dejavusans') == 0 && current_language.to_s.casecmp("vi") != 0
|
||||
# DejaVuSansMono Italic Arabic font has problem
|
||||
style.delete!('I') if family.to_s.casecmp('dejavusansmono') == 0
|
||||
super(family, style, size, fontfile)
|
||||
end
|
||||
alias_method :set_font, :SetFont
|
||||
|
||||
def fix_text_encoding(txt)
|
||||
RDMPdfEncoding::rdm_from_utf8(txt, "UTF-8")
|
||||
end
|
||||
|
||||
def formatted_text(text)
|
||||
Redmine::WikiFormatting.to_html(Setting.text_formatting, text)
|
||||
end
|
||||
|
||||
def RDMCell(w ,h=0, txt='', border=0, ln=0, align='', fill=0, link='')
|
||||
cell(w, h, txt, border, ln, align, fill, link)
|
||||
end
|
||||
|
||||
def RDMMultiCell(w, h=0, txt='', border=0, align='', fill=0, ln=1)
|
||||
multi_cell(w, h, txt, border, align, fill, ln)
|
||||
end
|
||||
|
||||
def RDMwriteFormattedCell(w, h, x, y, txt='', attachments=[], border=0, ln=1, fill=0)
|
||||
@attachments = attachments
|
||||
|
||||
css_tag = ' <style>
|
||||
table, td {
|
||||
border: 2px #ff0000 solid;
|
||||
}
|
||||
th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; text-align: center; font-style: bold;}
|
||||
pre {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
</style>'
|
||||
|
||||
# Strip {{toc}} tags
|
||||
txt.gsub!(/<p>\{\{([<>]?)toc\}\}<\/p>/i, '')
|
||||
writeHTMLCell(w, h, x, y, css_tag + txt, border, ln, fill)
|
||||
end
|
||||
|
||||
def RDMwriteHTMLCell(w, h, x, y, txt='', attachments=[], border=0, ln=1, fill=0)
|
||||
txt = formatted_text(txt)
|
||||
RDMwriteFormattedCell(w, h, x, y, txt, attachments, border, ln, fill)
|
||||
end
|
||||
|
||||
def get_image_filename(attrname)
|
||||
atta = RDMPdfEncoding.attach(@attachments, attrname, "UTF-8")
|
||||
if atta
|
||||
return atta.diskfile
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
def get_sever_url(url)
|
||||
if !empty_string(url) and (url[0, 1] == '/')
|
||||
Setting.host_name.split('/')[0] + url
|
||||
else
|
||||
url
|
||||
end
|
||||
end
|
||||
|
||||
def Footer
|
||||
set_font(@font_for_footer, 'I', 8)
|
||||
set_x(15)
|
||||
if get_rtl
|
||||
RDMCell(0, 5, @footer_date, 0, 0, 'R')
|
||||
else
|
||||
RDMCell(0, 5, @footer_date, 0, 0, 'L')
|
||||
end
|
||||
set_x(-30)
|
||||
RDMCell(0, 5, get_alias_num_page() + '/' + get_alias_nb_pages(), 0, 0, 'C')
|
||||
end
|
||||
end
|
||||
|
||||
class RDMPdfEncoding
|
||||
def self.rdm_from_utf8(txt, encoding)
|
||||
txt ||= ''
|
||||
txt = Redmine::CodesetUtil.from_utf8(txt, encoding)
|
||||
txt.force_encoding('ASCII-8BIT')
|
||||
txt
|
||||
end
|
||||
|
||||
def self.attach(attachments, filename, encoding)
|
||||
filename_utf8 = Redmine::CodesetUtil.to_utf8(filename, encoding)
|
||||
atta = nil
|
||||
if filename_utf8 =~ /^[^\/"]+\.(gif|jpg|jpe|jpeg|png)$/i
|
||||
atta = Attachment.latest_attach(attachments, filename_utf8)
|
||||
end
|
||||
if atta && atta.readable? && atta.visible?
|
||||
return atta
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
555
lib/redmine/export/pdf/issues_pdf_helper.rb
Normal file
555
lib/redmine/export/pdf/issues_pdf_helper.rb
Normal file
|
@ -0,0 +1,555 @@
|
|||
# encoding: utf-8
|
||||
#
|
||||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Export
|
||||
module PDF
|
||||
module IssuesPdfHelper
|
||||
# Returns a PDF string of a single issue
|
||||
def issue_to_pdf(issue, assoc={})
|
||||
pdf = ITCPDF.new(current_language)
|
||||
pdf.set_title("#{issue.project} - #{issue.tracker} ##{issue.id}")
|
||||
pdf.alias_nb_pages
|
||||
pdf.footer_date = format_date(User.current.today)
|
||||
pdf.add_page
|
||||
pdf.SetFontStyle('B',11)
|
||||
buf = "#{issue.project} - #{issue.tracker} ##{issue.id}"
|
||||
pdf.RDMMultiCell(190, 5, buf)
|
||||
pdf.SetFontStyle('',8)
|
||||
base_x = pdf.get_x
|
||||
i = 1
|
||||
issue.ancestors.visible.each do |ancestor|
|
||||
pdf.set_x(base_x + i)
|
||||
buf = "#{ancestor.tracker} # #{ancestor.id} (#{ancestor.status.to_s}): #{ancestor.subject}"
|
||||
pdf.RDMMultiCell(190 - i, 5, buf)
|
||||
i += 1 if i < 35
|
||||
end
|
||||
pdf.SetFontStyle('B',11)
|
||||
pdf.RDMMultiCell(190 - i, 5, issue.subject.to_s)
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMMultiCell(190, 5, "#{format_time(issue.created_on)} - #{issue.author}")
|
||||
pdf.ln
|
||||
|
||||
left = []
|
||||
left << [l(:field_status), issue.status]
|
||||
left << [l(:field_priority), issue.priority]
|
||||
left << [l(:field_assigned_to), issue.assigned_to] unless issue.disabled_core_fields.include?('assigned_to_id')
|
||||
left << [l(:field_category), issue.category] unless issue.disabled_core_fields.include?('category_id')
|
||||
left << [l(:field_fixed_version), issue.fixed_version] unless issue.disabled_core_fields.include?('fixed_version_id')
|
||||
|
||||
right = []
|
||||
right << [l(:field_start_date), format_date(issue.start_date)] unless issue.disabled_core_fields.include?('start_date')
|
||||
right << [l(:field_due_date), format_date(issue.due_date)] unless issue.disabled_core_fields.include?('due_date')
|
||||
right << [l(:field_done_ratio), "#{issue.done_ratio}%"] unless issue.disabled_core_fields.include?('done_ratio')
|
||||
right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?('estimated_hours')
|
||||
right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project)
|
||||
|
||||
rows = left.size > right.size ? left.size : right.size
|
||||
while left.size < rows
|
||||
left << nil
|
||||
end
|
||||
while right.size < rows
|
||||
right << nil
|
||||
end
|
||||
|
||||
custom_field_values = issue.visible_custom_field_values.reject {|value| value.custom_field.full_width_layout?}
|
||||
half = (custom_field_values.size / 2.0).ceil
|
||||
custom_field_values.each_with_index do |custom_value, i|
|
||||
(i < half ? left : right) << [custom_value.custom_field.name, show_value(custom_value, false)]
|
||||
end
|
||||
|
||||
if pdf.get_rtl
|
||||
border_first_top = 'RT'
|
||||
border_last_top = 'LT'
|
||||
border_first = 'R'
|
||||
border_last = 'L'
|
||||
else
|
||||
border_first_top = 'LT'
|
||||
border_last_top = 'RT'
|
||||
border_first = 'L'
|
||||
border_last = 'R'
|
||||
end
|
||||
|
||||
rows = left.size > right.size ? left.size : right.size
|
||||
rows.times do |i|
|
||||
heights = []
|
||||
pdf.SetFontStyle('B',9)
|
||||
item = left[i]
|
||||
heights << pdf.get_string_height(35, item ? "#{item.first}:" : "")
|
||||
item = right[i]
|
||||
heights << pdf.get_string_height(35, item ? "#{item.first}:" : "")
|
||||
pdf.SetFontStyle('',9)
|
||||
item = left[i]
|
||||
heights << pdf.get_string_height(60, item ? item.last.to_s : "")
|
||||
item = right[i]
|
||||
heights << pdf.get_string_height(60, item ? item.last.to_s : "")
|
||||
height = heights.max
|
||||
|
||||
item = left[i]
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0)
|
||||
pdf.SetFontStyle('',9)
|
||||
pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 0)
|
||||
|
||||
item = right[i]
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", (i == 0 ? border_first_top : border_first), '', 0, 0)
|
||||
pdf.SetFontStyle('',9)
|
||||
pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", (i == 0 ? border_last_top : border_last), '', 0, 2)
|
||||
|
||||
pdf.set_x(base_x)
|
||||
end
|
||||
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155, 5, l(:field_description), "LRT", 1)
|
||||
pdf.SetFontStyle('',9)
|
||||
|
||||
# Set resize image scale
|
||||
pdf.set_image_scale(1.6)
|
||||
text = textilizable(issue, :description,
|
||||
:only_path => false,
|
||||
:edit_section_links => false,
|
||||
:headings => false,
|
||||
:inline_attachments => false
|
||||
)
|
||||
pdf.RDMwriteFormattedCell(35+155, 5, '', '', text, issue.attachments, "LRB")
|
||||
|
||||
custom_field_values = issue.visible_custom_field_values.select {|value| value.custom_field.full_width_layout?}
|
||||
custom_field_values.each do |value|
|
||||
text = show_value(value, false)
|
||||
next if text.blank?
|
||||
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155, 5, value.custom_field.name, "LRT", 1)
|
||||
pdf.SetFontStyle('',9)
|
||||
pdf.RDMwriteHTMLCell(35+155, 5, '', '', text, issue.attachments, "LRB")
|
||||
end
|
||||
|
||||
unless issue.leaf?
|
||||
truncate_length = (!is_cjk? ? 90 : 65)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155,5, l(:label_subtask_plural) + ":", "LTR")
|
||||
pdf.ln
|
||||
issue_list(issue.descendants.visible.sort_by(&:lft)) do |child, level|
|
||||
buf = "#{child.tracker} # #{child.id}: #{child.subject}".
|
||||
truncate(truncate_length)
|
||||
level = 10 if level >= 10
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMCell(35+135,5, (level >=1 ? " " * level : "") + buf, border_first)
|
||||
pdf.SetFontStyle('B',8)
|
||||
pdf.RDMCell(20,5, child.status.to_s, border_last)
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
relations = issue.relations.select { |r| r.other_issue(issue).visible? }
|
||||
unless relations.empty?
|
||||
truncate_length = (!is_cjk? ? 80 : 60)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(35+155,5, l(:label_related_issues) + ":", "LTR")
|
||||
pdf.ln
|
||||
relations.each do |relation|
|
||||
buf = relation.to_s(issue) {|other|
|
||||
text = ""
|
||||
if Setting.cross_project_issue_relations?
|
||||
text += "#{relation.other_issue(issue).project} - "
|
||||
end
|
||||
text += "#{other.tracker} ##{other.id}: #{other.subject}"
|
||||
text
|
||||
}
|
||||
buf = buf.truncate(truncate_length)
|
||||
pdf.SetFontStyle('', 8)
|
||||
pdf.RDMCell(35+155-60, 5, buf, border_first)
|
||||
pdf.SetFontStyle('B',8)
|
||||
pdf.RDMCell(20,5, relation.other_issue(issue).status.to_s, "")
|
||||
pdf.RDMCell(20,5, format_date(relation.other_issue(issue).start_date), "")
|
||||
pdf.RDMCell(20,5, format_date(relation.other_issue(issue).due_date), border_last)
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
pdf.RDMCell(190,5, "", "T")
|
||||
pdf.ln
|
||||
|
||||
if issue.changesets.any? &&
|
||||
User.current.allowed_to?(:view_changesets, issue.project)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_associated_revisions), "B")
|
||||
pdf.ln
|
||||
for changeset in issue.changesets
|
||||
pdf.SetFontStyle('B',8)
|
||||
csstr = "#{l(:label_revision)} #{changeset.format_identifier} - "
|
||||
csstr += format_time(changeset.committed_on) + " - " + changeset.author.to_s
|
||||
pdf.RDMCell(190, 5, csstr)
|
||||
pdf.ln
|
||||
unless changeset.comments.blank?
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMwriteHTMLCell(190,5,'','',
|
||||
changeset.comments.to_s, issue.attachments, "")
|
||||
end
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
if assoc[:journals].present?
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_history), "B")
|
||||
pdf.ln
|
||||
assoc[:journals].each do |journal|
|
||||
pdf.SetFontStyle('B',8)
|
||||
title = "##{journal.indice} - #{format_time(journal.created_on)} - #{journal.user}"
|
||||
title << " (#{l(:field_private_notes)})" if journal.private_notes?
|
||||
pdf.RDMCell(190,5, title)
|
||||
pdf.ln
|
||||
pdf.SetFontStyle('I',8)
|
||||
details_to_strings(journal.visible_details, true).each do |string|
|
||||
pdf.RDMMultiCell(190,5, "- " + string)
|
||||
end
|
||||
if journal.notes?
|
||||
pdf.ln unless journal.details.empty?
|
||||
pdf.SetFontStyle('',8)
|
||||
text = textilizable(journal, :notes,
|
||||
:only_path => false,
|
||||
:edit_section_links => false,
|
||||
:headings => false,
|
||||
:inline_attachments => false
|
||||
)
|
||||
pdf.RDMwriteFormattedCell(190,5,'','', text, issue.attachments, "")
|
||||
end
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
|
||||
if issue.attachments.any?
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_attachment_plural), "B")
|
||||
pdf.ln
|
||||
for attachment in issue.attachments
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMCell(80,5, attachment.filename)
|
||||
pdf.RDMCell(20,5, number_to_human_size(attachment.filesize),0,0,"R")
|
||||
pdf.RDMCell(25,5, format_date(attachment.created_on),0,0,"R")
|
||||
pdf.RDMCell(65,5, attachment.author.name,0,0,"R")
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
pdf.output
|
||||
end
|
||||
|
||||
# Returns a PDF string of a list of issues
|
||||
def issues_to_pdf(issues, project, query)
|
||||
pdf = ITCPDF.new(current_language, "L")
|
||||
title = query.new_record? ? l(:label_issue_plural) : query.name
|
||||
title = "#{project} - #{title}" if project
|
||||
pdf.set_title(title)
|
||||
pdf.alias_nb_pages
|
||||
pdf.footer_date = format_date(User.current.today)
|
||||
pdf.set_auto_page_break(false)
|
||||
pdf.add_page("L")
|
||||
|
||||
# Landscape A4 = 210 x 297 mm
|
||||
page_height = pdf.get_page_height # 210
|
||||
page_width = pdf.get_page_width # 297
|
||||
left_margin = pdf.get_original_margins['left'] # 10
|
||||
right_margin = pdf.get_original_margins['right'] # 10
|
||||
bottom_margin = pdf.get_footer_margin
|
||||
row_height = 4
|
||||
|
||||
# column widths
|
||||
table_width = page_width - right_margin - left_margin
|
||||
col_width = []
|
||||
unless query.inline_columns.empty?
|
||||
col_width = calc_col_width(issues, query, table_width, pdf)
|
||||
table_width = col_width.inject(0, :+)
|
||||
end
|
||||
|
||||
# use full width if the description or last_notes are displayed
|
||||
if table_width > 0 && (query.has_column?(:description) || query.has_column?(:last_notes))
|
||||
col_width = col_width.map {|w| w * (page_width - right_margin - left_margin) / table_width}
|
||||
table_width = col_width.inject(0, :+)
|
||||
end
|
||||
|
||||
# title
|
||||
pdf.SetFontStyle('B',11)
|
||||
pdf.RDMCell(190, 8, title)
|
||||
pdf.ln
|
||||
|
||||
# totals
|
||||
totals = query.totals.map {|column, total| "#{column.caption}: #{total}"}
|
||||
if totals.present?
|
||||
pdf.SetFontStyle('B',10)
|
||||
pdf.RDMCell(table_width, 6, totals.join(" "), 0, 1, 'R')
|
||||
end
|
||||
|
||||
totals_by_group = query.totals_by_group
|
||||
render_table_header(pdf, query, col_width, row_height, table_width)
|
||||
previous_group = false
|
||||
result_count_by_group = query.result_count_by_group
|
||||
|
||||
issue_list(issues) do |issue, level|
|
||||
if query.grouped? &&
|
||||
(group = query.group_by_column.value(issue)) != previous_group
|
||||
pdf.SetFontStyle('B',10)
|
||||
group_label = group.blank? ? 'None' : group.to_s.dup
|
||||
group_label << " (#{result_count_by_group[group]})"
|
||||
pdf.bookmark group_label, 0, -1
|
||||
pdf.RDMCell(table_width, row_height * 2, group_label, 'LR', 1, 'L')
|
||||
pdf.SetFontStyle('',8)
|
||||
|
||||
totals = totals_by_group.map {|column, total| "#{column.caption}: #{total[group]}"}.join(" ")
|
||||
if totals.present?
|
||||
pdf.RDMCell(table_width, row_height, totals, 'LR', 1, 'L')
|
||||
end
|
||||
previous_group = group
|
||||
end
|
||||
|
||||
# fetch row values
|
||||
col_values = fetch_row_values(issue, query, level)
|
||||
|
||||
# make new page if it doesn't fit on the current one
|
||||
base_y = pdf.get_y
|
||||
max_height = get_issues_to_pdf_write_cells(pdf, col_values, col_width)
|
||||
space_left = page_height - base_y - bottom_margin
|
||||
if max_height > space_left
|
||||
pdf.add_page("L")
|
||||
render_table_header(pdf, query, col_width, row_height, table_width)
|
||||
base_y = pdf.get_y
|
||||
end
|
||||
|
||||
# write the cells on page
|
||||
issues_to_pdf_write_cells(pdf, col_values, col_width, max_height)
|
||||
pdf.set_y(base_y + max_height)
|
||||
|
||||
if query.has_column?(:description) && issue.description?
|
||||
pdf.set_x(10)
|
||||
pdf.set_auto_page_break(true, bottom_margin)
|
||||
pdf.RDMwriteHTMLCell(0, 5, 10, '', issue.description.to_s, issue.attachments, "LRBT")
|
||||
pdf.set_auto_page_break(false)
|
||||
end
|
||||
|
||||
if query.has_column?(:last_notes) && issue.last_notes.present?
|
||||
pdf.set_x(10)
|
||||
pdf.set_auto_page_break(true, bottom_margin)
|
||||
pdf.RDMwriteHTMLCell(0, 5, 10, '', issue.last_notes.to_s, [], "LRBT")
|
||||
pdf.set_auto_page_break(false)
|
||||
end
|
||||
end
|
||||
|
||||
if issues.size == Setting.issues_export_limit.to_i
|
||||
pdf.SetFontStyle('B',10)
|
||||
pdf.RDMCell(0, row_height, '...')
|
||||
end
|
||||
pdf.output
|
||||
end
|
||||
|
||||
def is_cjk?
|
||||
case current_language.to_s.downcase
|
||||
when 'ja', 'zh-tw', 'zh', 'ko'
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# fetch row values
|
||||
def fetch_row_values(issue, query, level)
|
||||
query.inline_columns.collect do |column|
|
||||
s = if column.is_a?(QueryCustomFieldColumn)
|
||||
cv = issue.visible_custom_field_values.detect {|v| v.custom_field_id == column.custom_field.id}
|
||||
show_value(cv, false)
|
||||
else
|
||||
value = issue.send(column.name)
|
||||
case column.name
|
||||
when :subject
|
||||
value = " " * level + value
|
||||
when :attachments
|
||||
value = value.to_a.map {|a| a.filename}.join("\n")
|
||||
end
|
||||
if value.is_a?(Date)
|
||||
format_date(value)
|
||||
elsif value.is_a?(Time)
|
||||
format_time(value)
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
s.to_s
|
||||
end
|
||||
end
|
||||
|
||||
# calculate columns width
|
||||
def calc_col_width(issues, query, table_width, pdf)
|
||||
# calculate statistics
|
||||
# by captions
|
||||
pdf.SetFontStyle('B',8)
|
||||
margins = pdf.get_margins
|
||||
col_padding = margins['cell']
|
||||
col_width_min = query.inline_columns.map {|v| pdf.get_string_width(v.caption) + col_padding}
|
||||
col_width_max = Array.new(col_width_min)
|
||||
col_width_avg = Array.new(col_width_min)
|
||||
col_min = pdf.get_string_width('OO') + col_padding * 2
|
||||
if table_width > col_min * col_width_avg.length
|
||||
table_width -= col_min * col_width_avg.length
|
||||
else
|
||||
col_min = pdf.get_string_width('O') + col_padding * 2
|
||||
if table_width > col_min * col_width_avg.length
|
||||
table_width -= col_min * col_width_avg.length
|
||||
else
|
||||
ratio = table_width / col_width_avg.inject(0, :+)
|
||||
return col_width = col_width_avg.map {|w| w * ratio}
|
||||
end
|
||||
end
|
||||
word_width_max = query.inline_columns.map {|c|
|
||||
n = 10
|
||||
c.caption.split.each {|w|
|
||||
x = pdf.get_string_width(w) + col_padding
|
||||
n = x if n < x
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
# by properties of issues
|
||||
pdf.SetFontStyle('',8)
|
||||
k = 1
|
||||
issue_list(issues) {|issue, level|
|
||||
k += 1
|
||||
values = fetch_row_values(issue, query, level)
|
||||
values.each_with_index {|v,i|
|
||||
n = pdf.get_string_width(v) + col_padding * 2
|
||||
col_width_max[i] = n if col_width_max[i] < n
|
||||
col_width_min[i] = n if col_width_min[i] > n
|
||||
col_width_avg[i] += n
|
||||
v.split.each {|w|
|
||||
x = pdf.get_string_width(w) + col_padding
|
||||
word_width_max[i] = x if word_width_max[i] < x
|
||||
}
|
||||
}
|
||||
}
|
||||
col_width_avg.map! {|x| x / k}
|
||||
|
||||
# calculate columns width
|
||||
ratio = table_width / col_width_avg.inject(0, :+)
|
||||
col_width = col_width_avg.map {|w| w * ratio}
|
||||
|
||||
# correct max word width if too many columns
|
||||
ratio = table_width / word_width_max.inject(0, :+)
|
||||
word_width_max.map! {|v| v * ratio} if ratio < 1
|
||||
|
||||
# correct and lock width of some columns
|
||||
done = 1
|
||||
col_fix = []
|
||||
col_width.each_with_index do |w,i|
|
||||
if w > col_width_max[i]
|
||||
col_width[i] = col_width_max[i]
|
||||
col_fix[i] = 1
|
||||
done = 0
|
||||
elsif w < word_width_max[i]
|
||||
col_width[i] = word_width_max[i]
|
||||
col_fix[i] = 1
|
||||
done = 0
|
||||
else
|
||||
col_fix[i] = 0
|
||||
end
|
||||
end
|
||||
|
||||
# iterate while need to correct and lock coluns width
|
||||
while done == 0
|
||||
# calculate free & locked columns width
|
||||
done = 1
|
||||
ratio = table_width / col_width.inject(0, :+)
|
||||
|
||||
# correct columns width
|
||||
col_width.each_with_index do |w,i|
|
||||
if col_fix[i] == 0
|
||||
col_width[i] = w * ratio
|
||||
|
||||
# check if column width less then max word width
|
||||
if col_width[i] < word_width_max[i]
|
||||
col_width[i] = word_width_max[i]
|
||||
col_fix[i] = 1
|
||||
done = 0
|
||||
elsif col_width[i] > col_width_max[i]
|
||||
col_width[i] = col_width_max[i]
|
||||
col_fix[i] = 1
|
||||
done = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ratio = table_width / col_width.inject(0, :+)
|
||||
col_width.map! {|v| v * ratio + col_min}
|
||||
col_width
|
||||
end
|
||||
|
||||
def render_table_header(pdf, query, col_width, row_height, table_width)
|
||||
# headers
|
||||
pdf.SetFontStyle('B',8)
|
||||
pdf.set_fill_color(230, 230, 230)
|
||||
|
||||
base_x = pdf.get_x
|
||||
base_y = pdf.get_y
|
||||
max_height = get_issues_to_pdf_write_cells(pdf, query.inline_columns, col_width, true)
|
||||
|
||||
# write the cells on page
|
||||
issues_to_pdf_write_cells(pdf, query.inline_columns, col_width, max_height, true)
|
||||
pdf.set_xy(base_x, base_y + max_height)
|
||||
|
||||
# rows
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.set_fill_color(255, 255, 255)
|
||||
end
|
||||
|
||||
# returns the maximum height of MultiCells
|
||||
def get_issues_to_pdf_write_cells(pdf, col_values, col_widths, head=false)
|
||||
heights = []
|
||||
col_values.each_with_index do |column, i|
|
||||
heights << pdf.get_string_height(col_widths[i], head ? column.caption : column)
|
||||
end
|
||||
return heights.max
|
||||
end
|
||||
|
||||
# Renders MultiCells and returns the maximum height used
|
||||
def issues_to_pdf_write_cells(pdf, col_values, col_widths, row_height, head=false)
|
||||
col_values.each_with_index do |column, i|
|
||||
pdf.RDMMultiCell(col_widths[i], row_height, head ? column.caption : column.strip, 1, '', 1, 0)
|
||||
end
|
||||
end
|
||||
|
||||
# Draw lines to close the row (MultiCell border drawing in not uniform)
|
||||
#
|
||||
# parameter "col_id_width" is not used. it is kept for compatibility.
|
||||
def issues_to_pdf_draw_borders(pdf, top_x, top_y, lower_y,
|
||||
col_id_width, col_widths, rtl=false)
|
||||
col_x = top_x
|
||||
pdf.line(col_x, top_y, col_x, lower_y) # id right border
|
||||
col_widths.each do |width|
|
||||
if rtl
|
||||
col_x -= width
|
||||
else
|
||||
col_x += width
|
||||
end
|
||||
pdf.line(col_x, top_y, col_x, lower_y) # columns right border
|
||||
end
|
||||
pdf.line(top_x, top_y, top_x, lower_y) # left border
|
||||
pdf.line(top_x, lower_y, col_x, lower_y) # bottom border
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
98
lib/redmine/export/pdf/wiki_pdf_helper.rb
Normal file
98
lib/redmine/export/pdf/wiki_pdf_helper.rb
Normal file
|
@ -0,0 +1,98 @@
|
|||
# encoding: utf-8
|
||||
#
|
||||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Export
|
||||
module PDF
|
||||
module WikiPdfHelper
|
||||
# Returns a PDF string of a set of wiki pages
|
||||
def wiki_pages_to_pdf(pages, project)
|
||||
pdf = Redmine::Export::PDF::ITCPDF.new(current_language)
|
||||
pdf.set_title(project.name)
|
||||
pdf.alias_nb_pages
|
||||
pdf.footer_date = format_date(User.current.today)
|
||||
pdf.add_page
|
||||
pdf.SetFontStyle('B',11)
|
||||
pdf.RDMMultiCell(190,5, project.name)
|
||||
pdf.ln
|
||||
# Set resize image scale
|
||||
pdf.set_image_scale(1.6)
|
||||
pdf.SetFontStyle('',9)
|
||||
write_page_hierarchy(pdf, pages.group_by(&:parent_id))
|
||||
pdf.output
|
||||
end
|
||||
|
||||
# Returns a PDF string of a single wiki page
|
||||
def wiki_page_to_pdf(page, project)
|
||||
pdf = ITCPDF.new(current_language)
|
||||
pdf.set_title("#{project} - #{page.title}")
|
||||
pdf.alias_nb_pages
|
||||
pdf.footer_date = format_date(User.current.today)
|
||||
pdf.add_page
|
||||
pdf.SetFontStyle('B',11)
|
||||
pdf.RDMMultiCell(190,5,
|
||||
"#{project} - #{page.title} - # #{page.content.version}")
|
||||
pdf.ln
|
||||
# Set resize image scale
|
||||
pdf.set_image_scale(1.6)
|
||||
pdf.SetFontStyle('',9)
|
||||
write_wiki_page(pdf, page)
|
||||
pdf.output
|
||||
end
|
||||
|
||||
def write_page_hierarchy(pdf, pages, node=nil, level=0)
|
||||
if pages[node]
|
||||
pages[node].each do |page|
|
||||
unless level == 0 && page == pages[node].first
|
||||
pdf.add_page
|
||||
end
|
||||
pdf.bookmark page.title, level
|
||||
write_wiki_page(pdf, page)
|
||||
write_page_hierarchy(pdf, pages, page.id, level + 1) if pages[page.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def write_wiki_page(pdf, page)
|
||||
text = textilizable(page.content, :text,
|
||||
:only_path => false,
|
||||
:edit_section_links => false,
|
||||
:headings => false,
|
||||
:inline_attachments => false
|
||||
)
|
||||
pdf.RDMwriteFormattedCell(190,5,'','', text, page.attachments, 0)
|
||||
if page.attachments.any?
|
||||
pdf.ln(5)
|
||||
pdf.SetFontStyle('B',9)
|
||||
pdf.RDMCell(190,5, l(:label_attachment_plural), "B")
|
||||
pdf.ln
|
||||
for attachment in page.attachments
|
||||
pdf.SetFontStyle('',8)
|
||||
pdf.RDMCell(80,5, attachment.filename)
|
||||
pdf.RDMCell(20,5, number_to_human_size(attachment.filesize),0,0,"R")
|
||||
pdf.RDMCell(25,5, format_date(attachment.created_on),0,0,"R")
|
||||
pdf.RDMCell(65,5, attachment.author.name,0,0,"R")
|
||||
pdf.ln
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
1010
lib/redmine/field_format.rb
Normal file
1010
lib/redmine/field_format.rb
Normal file
File diff suppressed because it is too large
Load diff
85
lib/redmine/helpers/calendar.rb
Normal file
85
lib/redmine/helpers/calendar.rb
Normal file
|
@ -0,0 +1,85 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Helpers
|
||||
|
||||
# Simple class to compute the start and end dates of a calendar
|
||||
class Calendar
|
||||
include Redmine::I18n
|
||||
attr_reader :startdt, :enddt
|
||||
|
||||
def initialize(date, lang = current_language, period = :month)
|
||||
@date = date
|
||||
@events = []
|
||||
@ending_events_by_days = {}
|
||||
@starting_events_by_days = {}
|
||||
set_language_if_valid lang
|
||||
case period
|
||||
when :month
|
||||
@startdt = Date.civil(date.year, date.month, 1)
|
||||
@enddt = (@startdt >> 1)-1
|
||||
# starts from the first day of the week
|
||||
@startdt = @startdt - (@startdt.cwday - first_wday)%7
|
||||
# ends on the last day of the week
|
||||
@enddt = @enddt + (last_wday - @enddt.cwday)%7
|
||||
when :week
|
||||
@startdt = date - (date.cwday - first_wday)%7
|
||||
@enddt = date + (last_wday - date.cwday)%7
|
||||
else
|
||||
raise 'Invalid period'
|
||||
end
|
||||
end
|
||||
|
||||
# Sets calendar events
|
||||
def events=(events)
|
||||
@events = events
|
||||
@ending_events_by_days = @events.group_by {|event| event.due_date}
|
||||
@starting_events_by_days = @events.group_by {|event| event.start_date}
|
||||
end
|
||||
|
||||
# Returns events for the given day
|
||||
def events_on(day)
|
||||
((@ending_events_by_days[day] || []) + (@starting_events_by_days[day] || [])).uniq
|
||||
end
|
||||
|
||||
# Calendar current month
|
||||
def month
|
||||
@date.month
|
||||
end
|
||||
|
||||
# Return the first day of week
|
||||
# 1 = Monday ... 7 = Sunday
|
||||
def first_wday
|
||||
case Setting.start_of_week.to_i
|
||||
when 1
|
||||
@first_dow ||= (1 - 1)%7 + 1
|
||||
when 6
|
||||
@first_dow ||= (6 - 1)%7 + 1
|
||||
when 7
|
||||
@first_dow ||= (7 - 1)%7 + 1
|
||||
else
|
||||
@first_dow ||= (l(:general_first_day_of_week).to_i - 1)%7 + 1
|
||||
end
|
||||
end
|
||||
|
||||
def last_wday
|
||||
@last_dow ||= (first_wday + 5)%7 + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
77
lib/redmine/helpers/diff.rb
Normal file
77
lib/redmine/helpers/diff.rb
Normal file
|
@ -0,0 +1,77 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'diff'
|
||||
|
||||
module Redmine
|
||||
module Helpers
|
||||
class Diff
|
||||
include ERB::Util
|
||||
include ActionView::Helpers::TagHelper
|
||||
include ActionView::Helpers::TextHelper
|
||||
include ActionView::Helpers::OutputSafetyHelper
|
||||
attr_reader :diff, :words
|
||||
|
||||
def initialize(content_to, content_from)
|
||||
@words = content_to.to_s.split(/(\s+)/)
|
||||
@words = @words.select {|word| word != ' '}
|
||||
words_from = content_from.to_s.split(/(\s+)/)
|
||||
words_from = words_from.select {|word| word != ' '}
|
||||
@diff = words_from.diff @words
|
||||
end
|
||||
|
||||
def to_html
|
||||
words = self.words.collect{|word| h(word)}
|
||||
words_add = 0
|
||||
words_del = 0
|
||||
dels = 0
|
||||
del_off = 0
|
||||
diff.diffs.each do |diff|
|
||||
add_at = nil
|
||||
add_to = nil
|
||||
del_at = nil
|
||||
deleted = ""
|
||||
diff.each do |change|
|
||||
pos = change[1]
|
||||
if change[0] == "+"
|
||||
add_at = pos + dels unless add_at
|
||||
add_to = pos + dels
|
||||
words_add += 1
|
||||
else
|
||||
del_at = pos unless del_at
|
||||
deleted << ' ' unless deleted.empty?
|
||||
deleted << change[2]
|
||||
words_del += 1
|
||||
end
|
||||
end
|
||||
if add_at
|
||||
words[add_at] = '<span class="diff_in">'.html_safe + words[add_at]
|
||||
words[add_to] = words[add_to] + '</span>'.html_safe
|
||||
end
|
||||
if del_at
|
||||
# deleted is not safe html at this point
|
||||
words.insert del_at - del_off + dels + words_add, '<span class="diff_out">'.html_safe + h(deleted) + '</span>'.html_safe
|
||||
dels += 1
|
||||
del_off += words_del
|
||||
words_del = 0
|
||||
end
|
||||
end
|
||||
safe_join(words, ' ')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
957
lib/redmine/helpers/gantt.rb
Normal file
957
lib/redmine/helpers/gantt.rb
Normal file
|
@ -0,0 +1,957 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Helpers
|
||||
# Simple class to handle gantt chart data
|
||||
class Gantt
|
||||
class MaxLinesLimitReached < Exception
|
||||
end
|
||||
|
||||
include ERB::Util
|
||||
include Redmine::I18n
|
||||
include Redmine::Utils::DateCalculation
|
||||
|
||||
# Relation types that are rendered
|
||||
DRAW_TYPES = {
|
||||
IssueRelation::TYPE_BLOCKS => { :landscape_margin => 16, :color => '#F34F4F' },
|
||||
IssueRelation::TYPE_PRECEDES => { :landscape_margin => 20, :color => '#628FEA' }
|
||||
}.freeze
|
||||
|
||||
# :nodoc:
|
||||
# Some utility methods for the PDF export
|
||||
class PDF
|
||||
MaxCharactorsForSubject = 45
|
||||
TotalWidth = 280
|
||||
LeftPaneWidth = 100
|
||||
|
||||
def self.right_pane_width
|
||||
TotalWidth - LeftPaneWidth
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, :truncated, :max_rows
|
||||
attr_accessor :query
|
||||
attr_accessor :project
|
||||
attr_accessor :view
|
||||
|
||||
def initialize(options={})
|
||||
options = options.dup
|
||||
if options[:year] && options[:year].to_i >0
|
||||
@year_from = options[:year].to_i
|
||||
if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
|
||||
@month_from = options[:month].to_i
|
||||
else
|
||||
@month_from = 1
|
||||
end
|
||||
else
|
||||
@month_from ||= User.current.today.month
|
||||
@year_from ||= User.current.today.year
|
||||
end
|
||||
zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
|
||||
@zoom = (zoom > 0 && zoom < 5) ? zoom : 2
|
||||
months = (options[:months] || User.current.pref[:gantt_months]).to_i
|
||||
@months = (months > 0 && months < 25) ? months : 6
|
||||
# Save gantt parameters as user preference (zoom and months count)
|
||||
if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] ||
|
||||
@months != User.current.pref[:gantt_months]))
|
||||
User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
|
||||
User.current.preference.save
|
||||
end
|
||||
@date_from = Date.civil(@year_from, @month_from, 1)
|
||||
@date_to = (@date_from >> @months) - 1
|
||||
@subjects = ''
|
||||
@lines = ''
|
||||
@number_of_rows = nil
|
||||
@truncated = false
|
||||
if options.has_key?(:max_rows)
|
||||
@max_rows = options[:max_rows]
|
||||
else
|
||||
@max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i
|
||||
end
|
||||
end
|
||||
|
||||
def common_params
|
||||
{ :controller => 'gantts', :action => 'show', :project_id => @project }
|
||||
end
|
||||
|
||||
def params
|
||||
common_params.merge({:zoom => zoom, :year => year_from,
|
||||
:month => month_from, :months => months})
|
||||
end
|
||||
|
||||
def params_previous
|
||||
common_params.merge({:year => (date_from << months).year,
|
||||
:month => (date_from << months).month,
|
||||
:zoom => zoom, :months => months})
|
||||
end
|
||||
|
||||
def params_next
|
||||
common_params.merge({:year => (date_from >> months).year,
|
||||
:month => (date_from >> months).month,
|
||||
:zoom => zoom, :months => months})
|
||||
end
|
||||
|
||||
# Returns the number of rows that will be rendered on the Gantt chart
|
||||
def number_of_rows
|
||||
return @number_of_rows if @number_of_rows
|
||||
rows = projects.inject(0) {|total, p| total += number_of_rows_on_project(p)}
|
||||
rows > @max_rows ? @max_rows : rows
|
||||
end
|
||||
|
||||
# Returns the number of rows that will be used to list a project on
|
||||
# the Gantt chart. This will recurse for each subproject.
|
||||
def number_of_rows_on_project(project)
|
||||
return 0 unless projects.include?(project)
|
||||
count = 1
|
||||
count += project_issues(project).size
|
||||
count += project_versions(project).size
|
||||
count
|
||||
end
|
||||
|
||||
# Renders the subjects of the Gantt chart, the left side.
|
||||
def subjects(options={})
|
||||
render(options.merge(:only => :subjects)) unless @subjects_rendered
|
||||
@subjects
|
||||
end
|
||||
|
||||
# Renders the lines of the Gantt chart, the right side
|
||||
def lines(options={})
|
||||
render(options.merge(:only => :lines)) unless @lines_rendered
|
||||
@lines
|
||||
end
|
||||
|
||||
# Returns issues that will be rendered
|
||||
def issues
|
||||
@issues ||= @query.issues(
|
||||
:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
|
||||
:order => "#{Project.table_name}.lft ASC, #{Issue.table_name}.id ASC",
|
||||
:limit => @max_rows
|
||||
)
|
||||
end
|
||||
|
||||
# Returns a hash of the relations between the issues that are present on the gantt
|
||||
# and that should be displayed, grouped by issue ids.
|
||||
def relations
|
||||
return @relations if @relations
|
||||
if issues.any?
|
||||
issue_ids = issues.map(&:id)
|
||||
@relations = IssueRelation.
|
||||
where(:issue_from_id => issue_ids, :issue_to_id => issue_ids, :relation_type => DRAW_TYPES.keys).
|
||||
group_by(&:issue_from_id)
|
||||
else
|
||||
@relations = {}
|
||||
end
|
||||
end
|
||||
|
||||
# Return all the project nodes that will be displayed
|
||||
def projects
|
||||
return @projects if @projects
|
||||
ids = issues.collect(&:project).uniq.collect(&:id)
|
||||
if ids.any?
|
||||
# All issues projects and their visible ancestors
|
||||
@projects = Project.visible.
|
||||
joins("LEFT JOIN #{Project.table_name} child ON #{Project.table_name}.lft <= child.lft AND #{Project.table_name}.rgt >= child.rgt").
|
||||
where("child.id IN (?)", ids).
|
||||
order("#{Project.table_name}.lft ASC").
|
||||
distinct.
|
||||
to_a
|
||||
else
|
||||
@projects = []
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the issues that belong to +project+
|
||||
def project_issues(project)
|
||||
@issues_by_project ||= issues.group_by(&:project)
|
||||
@issues_by_project[project] || []
|
||||
end
|
||||
|
||||
# Returns the distinct versions of the issues that belong to +project+
|
||||
def project_versions(project)
|
||||
project_issues(project).collect(&:fixed_version).compact.uniq
|
||||
end
|
||||
|
||||
# Returns the issues that belong to +project+ and are assigned to +version+
|
||||
def version_issues(project, version)
|
||||
project_issues(project).select {|issue| issue.fixed_version == version}
|
||||
end
|
||||
|
||||
def render(options={})
|
||||
options = {:top => 0, :top_increment => 20,
|
||||
:indent_increment => 20, :render => :subject,
|
||||
:format => :html}.merge(options)
|
||||
indent = options[:indent] || 4
|
||||
@subjects = '' unless options[:only] == :lines
|
||||
@lines = '' unless options[:only] == :subjects
|
||||
@number_of_rows = 0
|
||||
begin
|
||||
Project.project_tree(projects) do |project, level|
|
||||
options[:indent] = indent + level * options[:indent_increment]
|
||||
render_project(project, options)
|
||||
end
|
||||
rescue MaxLinesLimitReached
|
||||
@truncated = true
|
||||
end
|
||||
@subjects_rendered = true unless options[:only] == :lines
|
||||
@lines_rendered = true unless options[:only] == :subjects
|
||||
render_end(options)
|
||||
end
|
||||
|
||||
def render_project(project, options={})
|
||||
render_object_row(project, options)
|
||||
increment_indent(options) do
|
||||
# render issue that are not assigned to a version
|
||||
issues = project_issues(project).select {|i| i.fixed_version.nil?}
|
||||
render_issues(issues, options)
|
||||
# then render project versions and their issues
|
||||
versions = project_versions(project)
|
||||
self.class.sort_versions!(versions)
|
||||
versions.each do |version|
|
||||
render_version(project, version, options)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def render_version(project, version, options={})
|
||||
render_object_row(version, options)
|
||||
increment_indent(options) do
|
||||
issues = version_issues(project, version)
|
||||
render_issues(issues, options)
|
||||
end
|
||||
end
|
||||
|
||||
def render_issues(issues, options={})
|
||||
self.class.sort_issues!(issues)
|
||||
ancestors = []
|
||||
issues.each do |issue|
|
||||
while ancestors.any? && !issue.is_descendant_of?(ancestors.last)
|
||||
ancestors.pop
|
||||
decrement_indent(options)
|
||||
end
|
||||
render_object_row(issue, options)
|
||||
unless issue.leaf?
|
||||
ancestors << issue
|
||||
increment_indent(options)
|
||||
end
|
||||
end
|
||||
decrement_indent(options, ancestors.size)
|
||||
end
|
||||
|
||||
def render_object_row(object, options)
|
||||
class_name = object.class.name.downcase
|
||||
send("subject_for_#{class_name}", object, options) unless options[:only] == :lines
|
||||
send("line_for_#{class_name}", object, options) unless options[:only] == :subjects
|
||||
options[:top] += options[:top_increment]
|
||||
@number_of_rows += 1
|
||||
if @max_rows && @number_of_rows >= @max_rows
|
||||
raise MaxLinesLimitReached
|
||||
end
|
||||
end
|
||||
|
||||
def render_end(options={})
|
||||
case options[:format]
|
||||
when :pdf
|
||||
options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
|
||||
end
|
||||
end
|
||||
|
||||
def increment_indent(options, factor=1)
|
||||
options[:indent] += options[:indent_increment] * factor
|
||||
if block_given?
|
||||
yield
|
||||
decrement_indent(options, factor)
|
||||
end
|
||||
end
|
||||
|
||||
def decrement_indent(options, factor=1)
|
||||
increment_indent(options, -factor)
|
||||
end
|
||||
|
||||
def subject_for_project(project, options)
|
||||
subject(project.name, options, project)
|
||||
end
|
||||
|
||||
def line_for_project(project, options)
|
||||
# Skip projects that don't have a start_date or due date
|
||||
if project.is_a?(Project) && project.start_date && project.due_date
|
||||
label = project.name
|
||||
line(project.start_date, project.due_date, nil, true, label, options, project)
|
||||
end
|
||||
end
|
||||
|
||||
def subject_for_version(version, options)
|
||||
subject(version.to_s_with_project, options, version)
|
||||
end
|
||||
|
||||
def line_for_version(version, options)
|
||||
# Skip versions that don't have a start_date
|
||||
if version.is_a?(Version) && version.due_date && version.start_date
|
||||
label = "#{h(version)} #{h(version.completed_percent.to_f.round)}%"
|
||||
label = h("#{version.project} -") + label unless @project && @project == version.project
|
||||
line(version.start_date, version.due_date, version.completed_percent, true, label, options, version)
|
||||
end
|
||||
end
|
||||
|
||||
def subject_for_issue(issue, options)
|
||||
subject(issue.subject, options, issue)
|
||||
end
|
||||
|
||||
def line_for_issue(issue, options)
|
||||
# Skip issues that don't have a due_before (due_date or version's due_date)
|
||||
if issue.is_a?(Issue) && issue.due_before
|
||||
label = issue.status.name.dup
|
||||
unless issue.disabled_core_fields.include?('done_ratio')
|
||||
label << " #{issue.done_ratio}%"
|
||||
end
|
||||
markers = !issue.leaf?
|
||||
line(issue.start_date, issue.due_before, issue.done_ratio, markers, label, options, issue)
|
||||
end
|
||||
end
|
||||
|
||||
def subject(label, options, object=nil)
|
||||
send "#{options[:format]}_subject", options, label, object
|
||||
end
|
||||
|
||||
def line(start_date, end_date, done_ratio, markers, label, options, object=nil)
|
||||
options[:zoom] ||= 1
|
||||
options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
|
||||
coords = coordinates(start_date, end_date, done_ratio, options[:zoom])
|
||||
send "#{options[:format]}_task", options, coords, markers, label, object
|
||||
end
|
||||
|
||||
# Generates a gantt image
|
||||
# Only defined if RMagick is avalaible
|
||||
def to_image(format='PNG')
|
||||
date_to = (@date_from >> @months) - 1
|
||||
show_weeks = @zoom > 1
|
||||
show_days = @zoom > 2
|
||||
subject_width = 400
|
||||
header_height = 18
|
||||
# width of one day in pixels
|
||||
zoom = @zoom * 2
|
||||
g_width = (@date_to - @date_from + 1) * zoom
|
||||
g_height = 20 * number_of_rows + 30
|
||||
headers_height = (show_weeks ? 2 * header_height : header_height)
|
||||
height = g_height + headers_height
|
||||
imgl = Magick::ImageList.new
|
||||
imgl.new_image(subject_width + g_width + 1, height)
|
||||
gc = Magick::Draw.new
|
||||
gc.font = Redmine::Configuration['rmagick_font_path'] || ""
|
||||
# Subjects
|
||||
gc.stroke('transparent')
|
||||
subjects(:image => gc, :top => (headers_height + 20), :indent => 4, :format => :image)
|
||||
# Months headers
|
||||
month_f = @date_from
|
||||
left = subject_width
|
||||
@months.times do
|
||||
width = ((month_f >> 1) - month_f) * zoom
|
||||
gc.fill('white')
|
||||
gc.stroke('grey')
|
||||
gc.stroke_width(1)
|
||||
gc.rectangle(left, 0, left + width, height)
|
||||
gc.fill('black')
|
||||
gc.stroke('transparent')
|
||||
gc.stroke_width(1)
|
||||
gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
|
||||
left = left + width
|
||||
month_f = month_f >> 1
|
||||
end
|
||||
# Weeks headers
|
||||
if show_weeks
|
||||
left = subject_width
|
||||
height = header_height
|
||||
if @date_from.cwday == 1
|
||||
# date_from is monday
|
||||
week_f = date_from
|
||||
else
|
||||
# find next monday after date_from
|
||||
week_f = @date_from + (7 - @date_from.cwday + 1)
|
||||
width = (7 - @date_from.cwday + 1) * zoom
|
||||
gc.fill('white')
|
||||
gc.stroke('grey')
|
||||
gc.stroke_width(1)
|
||||
gc.rectangle(left, header_height, left + width, 2 * header_height + g_height - 1)
|
||||
left = left + width
|
||||
end
|
||||
while week_f <= date_to
|
||||
width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
|
||||
gc.fill('white')
|
||||
gc.stroke('grey')
|
||||
gc.stroke_width(1)
|
||||
gc.rectangle(left.round, header_height, left.round + width, 2 * header_height + g_height - 1)
|
||||
gc.fill('black')
|
||||
gc.stroke('transparent')
|
||||
gc.stroke_width(1)
|
||||
gc.text(left.round + 2, header_height + 14, week_f.cweek.to_s)
|
||||
left = left + width
|
||||
week_f = week_f + 7
|
||||
end
|
||||
end
|
||||
# Days details (week-end in grey)
|
||||
if show_days
|
||||
left = subject_width
|
||||
height = g_height + header_height - 1
|
||||
wday = @date_from.cwday
|
||||
(date_to - @date_from + 1).to_i.times do
|
||||
width = zoom
|
||||
gc.fill(non_working_week_days.include?(wday) ? '#eee' : 'white')
|
||||
gc.stroke('#ddd')
|
||||
gc.stroke_width(1)
|
||||
gc.rectangle(left, 2 * header_height, left + width, 2 * header_height + g_height - 1)
|
||||
left = left + width
|
||||
wday = wday + 1
|
||||
wday = 1 if wday > 7
|
||||
end
|
||||
end
|
||||
# border
|
||||
gc.fill('transparent')
|
||||
gc.stroke('grey')
|
||||
gc.stroke_width(1)
|
||||
gc.rectangle(0, 0, subject_width + g_width, headers_height)
|
||||
gc.stroke('black')
|
||||
gc.rectangle(0, 0, subject_width + g_width, g_height + headers_height - 1)
|
||||
# content
|
||||
top = headers_height + 20
|
||||
gc.stroke('transparent')
|
||||
lines(:image => gc, :top => top, :zoom => zoom,
|
||||
:subject_width => subject_width, :format => :image)
|
||||
# today red line
|
||||
if User.current.today >= @date_from and User.current.today <= date_to
|
||||
gc.stroke('red')
|
||||
x = (User.current.today - @date_from + 1) * zoom + subject_width
|
||||
gc.line(x, headers_height, x, headers_height + g_height - 1)
|
||||
end
|
||||
gc.draw(imgl)
|
||||
imgl.format = format
|
||||
imgl.to_blob
|
||||
end if Object.const_defined?(:Magick)
|
||||
|
||||
def to_pdf
|
||||
pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
|
||||
pdf.SetTitle("#{l(:label_gantt)} #{project}")
|
||||
pdf.alias_nb_pages
|
||||
pdf.footer_date = format_date(User.current.today)
|
||||
pdf.AddPage("L")
|
||||
pdf.SetFontStyle('B', 12)
|
||||
pdf.SetX(15)
|
||||
pdf.RDMCell(PDF::LeftPaneWidth, 20, project.to_s)
|
||||
pdf.Ln
|
||||
pdf.SetFontStyle('B', 9)
|
||||
subject_width = PDF::LeftPaneWidth
|
||||
header_height = 5
|
||||
headers_height = header_height
|
||||
show_weeks = false
|
||||
show_days = false
|
||||
if self.months < 7
|
||||
show_weeks = true
|
||||
headers_height = 2 * header_height
|
||||
if self.months < 3
|
||||
show_days = true
|
||||
headers_height = 3 * header_height
|
||||
if self.months < 2
|
||||
show_day_num = true
|
||||
headers_height = 4 * header_height
|
||||
end
|
||||
end
|
||||
end
|
||||
g_width = PDF.right_pane_width
|
||||
zoom = (g_width) / (self.date_to - self.date_from + 1)
|
||||
g_height = 120
|
||||
t_height = g_height + headers_height
|
||||
y_start = pdf.GetY
|
||||
# Months headers
|
||||
month_f = self.date_from
|
||||
left = subject_width
|
||||
height = header_height
|
||||
self.months.times do
|
||||
width = ((month_f >> 1) - month_f) * zoom
|
||||
pdf.SetY(y_start)
|
||||
pdf.SetX(left)
|
||||
pdf.RDMCell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
|
||||
left = left + width
|
||||
month_f = month_f >> 1
|
||||
end
|
||||
# Weeks headers
|
||||
if show_weeks
|
||||
left = subject_width
|
||||
height = header_height
|
||||
if self.date_from.cwday == 1
|
||||
# self.date_from is monday
|
||||
week_f = self.date_from
|
||||
else
|
||||
# find next monday after self.date_from
|
||||
week_f = self.date_from + (7 - self.date_from.cwday + 1)
|
||||
width = (7 - self.date_from.cwday + 1) * zoom-1
|
||||
pdf.SetY(y_start + header_height)
|
||||
pdf.SetX(left)
|
||||
pdf.RDMCell(width + 1, height, "", "LTR")
|
||||
left = left + width + 1
|
||||
end
|
||||
while week_f <= self.date_to
|
||||
width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
|
||||
pdf.SetY(y_start + header_height)
|
||||
pdf.SetX(left)
|
||||
pdf.RDMCell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
|
||||
left = left + width
|
||||
week_f = week_f + 7
|
||||
end
|
||||
end
|
||||
# Day numbers headers
|
||||
if show_day_num
|
||||
left = subject_width
|
||||
height = header_height
|
||||
day_num = self.date_from
|
||||
wday = self.date_from.cwday
|
||||
pdf.SetFontStyle('B', 7)
|
||||
(self.date_to - self.date_from + 1).to_i.times do
|
||||
width = zoom
|
||||
pdf.SetY(y_start + header_height * 2)
|
||||
pdf.SetX(left)
|
||||
pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
|
||||
pdf.RDMCell(width, height, day_num.day.to_s, "LTR", 0, "C")
|
||||
left = left + width
|
||||
day_num = day_num + 1
|
||||
wday = wday + 1
|
||||
wday = 1 if wday > 7
|
||||
end
|
||||
end
|
||||
# Days headers
|
||||
if show_days
|
||||
left = subject_width
|
||||
height = header_height
|
||||
wday = self.date_from.cwday
|
||||
pdf.SetFontStyle('B', 7)
|
||||
(self.date_to - self.date_from + 1).to_i.times do
|
||||
width = zoom
|
||||
pdf.SetY(y_start + header_height * (show_day_num ? 3 : 2))
|
||||
pdf.SetX(left)
|
||||
pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
|
||||
pdf.RDMCell(width, height, day_name(wday).first, "LTR", 0, "C")
|
||||
left = left + width
|
||||
wday = wday + 1
|
||||
wday = 1 if wday > 7
|
||||
end
|
||||
end
|
||||
pdf.SetY(y_start)
|
||||
pdf.SetX(15)
|
||||
pdf.SetTextColor(0)
|
||||
pdf.RDMCell(subject_width + g_width - 15, headers_height, "", 1)
|
||||
# Tasks
|
||||
top = headers_height + y_start
|
||||
options = {
|
||||
:top => top,
|
||||
:zoom => zoom,
|
||||
:subject_width => subject_width,
|
||||
:g_width => g_width,
|
||||
:indent => 0,
|
||||
:indent_increment => 5,
|
||||
:top_increment => 5,
|
||||
:format => :pdf,
|
||||
:pdf => pdf
|
||||
}
|
||||
render(options)
|
||||
pdf.Output
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def coordinates(start_date, end_date, progress, zoom=nil)
|
||||
zoom ||= @zoom
|
||||
coords = {}
|
||||
if start_date && end_date && start_date < self.date_to && end_date > self.date_from
|
||||
if start_date > self.date_from
|
||||
coords[:start] = start_date - self.date_from
|
||||
coords[:bar_start] = start_date - self.date_from
|
||||
else
|
||||
coords[:bar_start] = 0
|
||||
end
|
||||
if end_date < self.date_to
|
||||
coords[:end] = end_date - self.date_from
|
||||
coords[:bar_end] = end_date - self.date_from + 1
|
||||
else
|
||||
coords[:bar_end] = self.date_to - self.date_from + 1
|
||||
end
|
||||
if progress
|
||||
progress_date = calc_progress_date(start_date, end_date, progress)
|
||||
if progress_date > self.date_from && progress_date > start_date
|
||||
if progress_date < self.date_to
|
||||
coords[:bar_progress_end] = progress_date - self.date_from
|
||||
else
|
||||
coords[:bar_progress_end] = self.date_to - self.date_from + 1
|
||||
end
|
||||
end
|
||||
if progress_date < User.current.today
|
||||
late_date = [User.current.today, end_date].min
|
||||
if late_date > self.date_from && late_date > start_date
|
||||
if late_date < self.date_to
|
||||
coords[:bar_late_end] = late_date - self.date_from + 1
|
||||
else
|
||||
coords[:bar_late_end] = self.date_to - self.date_from + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# Transforms dates into pixels witdh
|
||||
coords.keys.each do |key|
|
||||
coords[key] = (coords[key] * zoom).floor
|
||||
end
|
||||
coords
|
||||
end
|
||||
|
||||
def calc_progress_date(start_date, end_date, progress)
|
||||
start_date + (end_date - start_date + 1) * (progress / 100.0)
|
||||
end
|
||||
|
||||
def self.sort_issues!(issues)
|
||||
issues.sort! {|a, b| sort_issue_logic(a) <=> sort_issue_logic(b)}
|
||||
end
|
||||
|
||||
def self.sort_issue_logic(issue)
|
||||
julian_date = Date.new()
|
||||
ancesters_start_date = []
|
||||
current_issue = issue
|
||||
begin
|
||||
ancesters_start_date.unshift([current_issue.start_date || julian_date, current_issue.id])
|
||||
current_issue = current_issue.parent
|
||||
end while (current_issue)
|
||||
ancesters_start_date
|
||||
end
|
||||
|
||||
def self.sort_versions!(versions)
|
||||
versions.sort!
|
||||
end
|
||||
|
||||
def pdf_new_page?(options)
|
||||
if options[:top] > 180
|
||||
options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
|
||||
options[:pdf].AddPage("L")
|
||||
options[:top] = 15
|
||||
options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
|
||||
end
|
||||
end
|
||||
|
||||
def html_subject_content(object)
|
||||
case object
|
||||
when Issue
|
||||
issue = object
|
||||
css_classes = ''
|
||||
css_classes << ' issue-overdue' if issue.overdue?
|
||||
css_classes << ' issue-behind-schedule' if issue.behind_schedule?
|
||||
css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
|
||||
css_classes << ' issue-closed' if issue.closed?
|
||||
if issue.start_date && issue.due_before && issue.done_ratio
|
||||
progress_date = calc_progress_date(issue.start_date,
|
||||
issue.due_before, issue.done_ratio)
|
||||
css_classes << ' behind-start-date' if progress_date < self.date_from
|
||||
css_classes << ' over-end-date' if progress_date > self.date_to
|
||||
end
|
||||
s = "".html_safe
|
||||
if issue.assigned_to.present?
|
||||
assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
|
||||
s << view.avatar(issue.assigned_to,
|
||||
:class => 'gravatar icon-gravatar',
|
||||
:size => 13,
|
||||
:title => assigned_string).to_s.html_safe
|
||||
end
|
||||
s << view.link_to_issue(issue).html_safe
|
||||
view.content_tag(:span, s, :class => css_classes).html_safe
|
||||
when Version
|
||||
version = object
|
||||
html_class = ""
|
||||
html_class << 'icon icon-package '
|
||||
html_class << (version.behind_schedule? ? 'version-behind-schedule' : '') << " "
|
||||
html_class << (version.overdue? ? 'version-overdue' : '')
|
||||
html_class << ' version-closed' unless version.open?
|
||||
if version.start_date && version.due_date && version.completed_percent
|
||||
progress_date = calc_progress_date(version.start_date,
|
||||
version.due_date, version.completed_percent)
|
||||
html_class << ' behind-start-date' if progress_date < self.date_from
|
||||
html_class << ' over-end-date' if progress_date > self.date_to
|
||||
end
|
||||
s = view.link_to_version(version).html_safe
|
||||
view.content_tag(:span, s, :class => html_class).html_safe
|
||||
when Project
|
||||
project = object
|
||||
html_class = ""
|
||||
html_class << 'icon icon-projects '
|
||||
html_class << (project.overdue? ? 'project-overdue' : '')
|
||||
s = view.link_to_project(project).html_safe
|
||||
view.content_tag(:span, s, :class => html_class).html_safe
|
||||
end
|
||||
end
|
||||
|
||||
def html_subject(params, subject, object)
|
||||
style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;"
|
||||
style << "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width]
|
||||
content = html_subject_content(object) || subject
|
||||
tag_options = {:style => style}
|
||||
case object
|
||||
when Issue
|
||||
tag_options[:id] = "issue-#{object.id}"
|
||||
tag_options[:class] = "issue-subject"
|
||||
tag_options[:title] = object.subject
|
||||
when Version
|
||||
tag_options[:id] = "version-#{object.id}"
|
||||
tag_options[:class] = "version-name"
|
||||
when Project
|
||||
tag_options[:class] = "project-name"
|
||||
end
|
||||
output = view.content_tag(:div, content, tag_options)
|
||||
@subjects << output
|
||||
output
|
||||
end
|
||||
|
||||
def pdf_subject(params, subject, options={})
|
||||
pdf_new_page?(params)
|
||||
params[:pdf].SetY(params[:top])
|
||||
params[:pdf].SetX(15)
|
||||
char_limit = PDF::MaxCharactorsForSubject - params[:indent]
|
||||
params[:pdf].RDMCell(params[:subject_width] - 15, 5,
|
||||
(" " * params[:indent]) +
|
||||
subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'),
|
||||
"LR")
|
||||
params[:pdf].SetY(params[:top])
|
||||
params[:pdf].SetX(params[:subject_width])
|
||||
params[:pdf].RDMCell(params[:g_width], 5, "", "LR")
|
||||
end
|
||||
|
||||
def image_subject(params, subject, options={})
|
||||
params[:image].fill('black')
|
||||
params[:image].stroke('transparent')
|
||||
params[:image].stroke_width(1)
|
||||
params[:image].text(params[:indent], params[:top] + 2, subject)
|
||||
end
|
||||
|
||||
def issue_relations(issue)
|
||||
rels = {}
|
||||
if relations[issue.id]
|
||||
relations[issue.id].each do |relation|
|
||||
(rels[relation.relation_type] ||= []) << relation.issue_to_id
|
||||
end
|
||||
end
|
||||
rels
|
||||
end
|
||||
|
||||
def html_task(params, coords, markers, label, object)
|
||||
output = ''
|
||||
|
||||
css = "task " + case object
|
||||
when Project
|
||||
"project"
|
||||
when Version
|
||||
"version"
|
||||
when Issue
|
||||
object.leaf? ? 'leaf' : 'parent'
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
# Renders the task bar, with progress and late
|
||||
if coords[:bar_start] && coords[:bar_end]
|
||||
width = coords[:bar_end] - coords[:bar_start] - 2
|
||||
style = ""
|
||||
style << "top:#{params[:top]}px;"
|
||||
style << "left:#{coords[:bar_start]}px;"
|
||||
style << "width:#{width}px;"
|
||||
html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue)
|
||||
html_id = "task-todo-version-#{object.id}" if object.is_a?(Version)
|
||||
content_opt = {:style => style,
|
||||
:class => "#{css} task_todo",
|
||||
:id => html_id}
|
||||
if object.is_a?(Issue)
|
||||
rels = issue_relations(object)
|
||||
if rels.present?
|
||||
content_opt[:data] = {"rels" => rels.to_json}
|
||||
end
|
||||
end
|
||||
output << view.content_tag(:div, ' '.html_safe, content_opt)
|
||||
if coords[:bar_late_end]
|
||||
width = coords[:bar_late_end] - coords[:bar_start] - 2
|
||||
style = ""
|
||||
style << "top:#{params[:top]}px;"
|
||||
style << "left:#{coords[:bar_start]}px;"
|
||||
style << "width:#{width}px;"
|
||||
output << view.content_tag(:div, ' '.html_safe,
|
||||
:style => style,
|
||||
:class => "#{css} task_late")
|
||||
end
|
||||
if coords[:bar_progress_end]
|
||||
width = coords[:bar_progress_end] - coords[:bar_start] - 2
|
||||
style = ""
|
||||
style << "top:#{params[:top]}px;"
|
||||
style << "left:#{coords[:bar_start]}px;"
|
||||
style << "width:#{width}px;"
|
||||
html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue)
|
||||
html_id = "task-done-version-#{object.id}" if object.is_a?(Version)
|
||||
output << view.content_tag(:div, ' '.html_safe,
|
||||
:style => style,
|
||||
:class => "#{css} task_done",
|
||||
:id => html_id)
|
||||
end
|
||||
end
|
||||
# Renders the markers
|
||||
if markers
|
||||
if coords[:start]
|
||||
style = ""
|
||||
style << "top:#{params[:top]}px;"
|
||||
style << "left:#{coords[:start]}px;"
|
||||
style << "width:15px;"
|
||||
output << view.content_tag(:div, ' '.html_safe,
|
||||
:style => style,
|
||||
:class => "#{css} marker starting")
|
||||
end
|
||||
if coords[:end]
|
||||
style = ""
|
||||
style << "top:#{params[:top]}px;"
|
||||
style << "left:#{coords[:end] + params[:zoom]}px;"
|
||||
style << "width:15px;"
|
||||
output << view.content_tag(:div, ' '.html_safe,
|
||||
:style => style,
|
||||
:class => "#{css} marker ending")
|
||||
end
|
||||
end
|
||||
# Renders the label on the right
|
||||
if label
|
||||
style = ""
|
||||
style << "top:#{params[:top]}px;"
|
||||
style << "left:#{(coords[:bar_end] || 0) + 8}px;"
|
||||
style << "width:15px;"
|
||||
output << view.content_tag(:div, label,
|
||||
:style => style,
|
||||
:class => "#{css} label")
|
||||
end
|
||||
# Renders the tooltip
|
||||
if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end]
|
||||
s = view.content_tag(:span,
|
||||
view.render_issue_tooltip(object).html_safe,
|
||||
:class => "tip")
|
||||
style = ""
|
||||
style << "position: absolute;"
|
||||
style << "top:#{params[:top]}px;"
|
||||
style << "left:#{coords[:bar_start]}px;"
|
||||
style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
|
||||
style << "height:12px;"
|
||||
output << view.content_tag(:div, s.html_safe,
|
||||
:style => style,
|
||||
:class => "tooltip")
|
||||
end
|
||||
@lines << output
|
||||
output
|
||||
end
|
||||
|
||||
def pdf_task(params, coords, markers, label, object)
|
||||
cell_height_ratio = params[:pdf].get_cell_height_ratio()
|
||||
params[:pdf].set_cell_height_ratio(0.1)
|
||||
|
||||
height = 2
|
||||
height /= 2 if markers
|
||||
# Renders the task bar, with progress and late
|
||||
if coords[:bar_start] && coords[:bar_end]
|
||||
params[:pdf].SetY(params[:top] + 1.5)
|
||||
params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
|
||||
params[:pdf].SetFillColor(200, 200, 200)
|
||||
params[:pdf].RDMCell(coords[:bar_end] - coords[:bar_start], height, "", 0, 0, "", 1)
|
||||
if coords[:bar_late_end]
|
||||
params[:pdf].SetY(params[:top] + 1.5)
|
||||
params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
|
||||
params[:pdf].SetFillColor(255, 100, 100)
|
||||
params[:pdf].RDMCell(coords[:bar_late_end] - coords[:bar_start], height, "", 0, 0, "", 1)
|
||||
end
|
||||
if coords[:bar_progress_end]
|
||||
params[:pdf].SetY(params[:top] + 1.5)
|
||||
params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
|
||||
params[:pdf].SetFillColor(90, 200, 90)
|
||||
params[:pdf].RDMCell(coords[:bar_progress_end] - coords[:bar_start], height, "", 0, 0, "", 1)
|
||||
end
|
||||
end
|
||||
# Renders the markers
|
||||
if markers
|
||||
if coords[:start]
|
||||
params[:pdf].SetY(params[:top] + 1)
|
||||
params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
|
||||
params[:pdf].SetFillColor(50, 50, 200)
|
||||
params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
|
||||
end
|
||||
if coords[:end]
|
||||
params[:pdf].SetY(params[:top] + 1)
|
||||
params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
|
||||
params[:pdf].SetFillColor(50, 50, 200)
|
||||
params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
|
||||
end
|
||||
end
|
||||
# Renders the label on the right
|
||||
if label
|
||||
params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
|
||||
params[:pdf].RDMCell(30, 2, label)
|
||||
end
|
||||
|
||||
params[:pdf].set_cell_height_ratio(cell_height_ratio)
|
||||
end
|
||||
|
||||
def image_task(params, coords, markers, label, object)
|
||||
height = 6
|
||||
height /= 2 if markers
|
||||
# Renders the task bar, with progress and late
|
||||
if coords[:bar_start] && coords[:bar_end]
|
||||
params[:image].fill('#aaa')
|
||||
params[:image].rectangle(params[:subject_width] + coords[:bar_start],
|
||||
params[:top],
|
||||
params[:subject_width] + coords[:bar_end],
|
||||
params[:top] - height)
|
||||
if coords[:bar_late_end]
|
||||
params[:image].fill('#f66')
|
||||
params[:image].rectangle(params[:subject_width] + coords[:bar_start],
|
||||
params[:top],
|
||||
params[:subject_width] + coords[:bar_late_end],
|
||||
params[:top] - height)
|
||||
end
|
||||
if coords[:bar_progress_end]
|
||||
params[:image].fill('#00c600')
|
||||
params[:image].rectangle(params[:subject_width] + coords[:bar_start],
|
||||
params[:top],
|
||||
params[:subject_width] + coords[:bar_progress_end],
|
||||
params[:top] - height)
|
||||
end
|
||||
end
|
||||
# Renders the markers
|
||||
if markers
|
||||
if coords[:start]
|
||||
x = params[:subject_width] + coords[:start]
|
||||
y = params[:top] - height / 2
|
||||
params[:image].fill('blue')
|
||||
params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
|
||||
end
|
||||
if coords[:end]
|
||||
x = params[:subject_width] + coords[:end] + params[:zoom]
|
||||
y = params[:top] - height / 2
|
||||
params[:image].fill('blue')
|
||||
params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
|
||||
end
|
||||
end
|
||||
# Renders the label on the right
|
||||
if label
|
||||
params[:image].fill('black')
|
||||
params[:image].text(params[:subject_width] + (coords[:bar_end] || 0) + 5,
|
||||
params[:top] + 1,
|
||||
label)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
153
lib/redmine/helpers/time_report.rb
Normal file
153
lib/redmine/helpers/time_report.rb
Normal file
|
@ -0,0 +1,153 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Helpers
|
||||
class TimeReport
|
||||
attr_reader :criteria, :columns, :hours, :total_hours, :periods
|
||||
|
||||
def initialize(project, issue, criteria, columns, time_entry_scope)
|
||||
@project = project
|
||||
@issue = issue
|
||||
|
||||
@criteria = criteria || []
|
||||
@criteria = @criteria.select{|criteria| available_criteria.has_key? criteria}
|
||||
@criteria.uniq!
|
||||
@criteria = @criteria[0,3]
|
||||
|
||||
@columns = (columns && %w(year month week day).include?(columns)) ? columns : 'month'
|
||||
@scope = time_entry_scope
|
||||
|
||||
run
|
||||
end
|
||||
|
||||
def available_criteria
|
||||
@available_criteria || load_available_criteria
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run
|
||||
unless @criteria.empty?
|
||||
time_columns = %w(tyear tmonth tweek spent_on)
|
||||
@hours = []
|
||||
@scope.includes(:activity).
|
||||
reorder(nil).
|
||||
group(@criteria.collect{|criteria| @available_criteria[criteria][:sql]} + time_columns).
|
||||
joins(@criteria.collect{|criteria| @available_criteria[criteria][:joins]}.compact).
|
||||
sum(:hours).each do |hash, hours|
|
||||
h = {'hours' => hours}
|
||||
(@criteria + time_columns).each_with_index do |name, i|
|
||||
h[name] = hash[i]
|
||||
end
|
||||
@hours << h
|
||||
end
|
||||
|
||||
@hours.each do |row|
|
||||
case @columns
|
||||
when 'year'
|
||||
row['year'] = row['tyear']
|
||||
when 'month'
|
||||
row['month'] = "#{row['tyear']}-#{row['tmonth']}"
|
||||
when 'week'
|
||||
row['week'] = "#{row['spent_on'].cwyear}-#{row['tweek']}"
|
||||
when 'day'
|
||||
row['day'] = "#{row['spent_on']}"
|
||||
end
|
||||
end
|
||||
|
||||
min = @hours.collect {|row| row['spent_on']}.min
|
||||
@from = min ? min.to_date : User.current.today
|
||||
|
||||
max = @hours.collect {|row| row['spent_on']}.max
|
||||
@to = max ? max.to_date : User.current.today
|
||||
|
||||
@total_hours = @hours.inject(0) {|s,k| s = s + k['hours'].to_f}
|
||||
|
||||
@periods = []
|
||||
# Date#at_beginning_of_ not supported in Rails 1.2.x
|
||||
date_from = @from.to_time
|
||||
# 100 columns max
|
||||
while date_from <= @to.to_time && @periods.length < 100
|
||||
case @columns
|
||||
when 'year'
|
||||
@periods << "#{date_from.year}"
|
||||
date_from = (date_from + 1.year).at_beginning_of_year
|
||||
when 'month'
|
||||
@periods << "#{date_from.year}-#{date_from.month}"
|
||||
date_from = (date_from + 1.month).at_beginning_of_month
|
||||
when 'week'
|
||||
@periods << "#{date_from.to_date.cwyear}-#{date_from.to_date.cweek}"
|
||||
date_from = (date_from + 7.day).at_beginning_of_week
|
||||
when 'day'
|
||||
@periods << "#{date_from.to_date}"
|
||||
date_from = date_from + 1.day
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def load_available_criteria
|
||||
@available_criteria = { 'project' => {:sql => "#{TimeEntry.table_name}.project_id",
|
||||
:klass => Project,
|
||||
:label => :label_project},
|
||||
'status' => {:sql => "#{Issue.table_name}.status_id",
|
||||
:klass => IssueStatus,
|
||||
:label => :field_status},
|
||||
'version' => {:sql => "#{Issue.table_name}.fixed_version_id",
|
||||
:klass => ::Version,
|
||||
:label => :label_version},
|
||||
'category' => {:sql => "#{Issue.table_name}.category_id",
|
||||
:klass => IssueCategory,
|
||||
:label => :field_category},
|
||||
'user' => {:sql => "#{TimeEntry.table_name}.user_id",
|
||||
:klass => User,
|
||||
:label => :label_user},
|
||||
'tracker' => {:sql => "#{Issue.table_name}.tracker_id",
|
||||
:klass => Tracker,
|
||||
:label => :label_tracker},
|
||||
'activity' => {:sql => "#{TimeEntry.table_name}.activity_id",
|
||||
:klass => TimeEntryActivity,
|
||||
:label => :label_activity},
|
||||
'issue' => {:sql => "#{TimeEntry.table_name}.issue_id",
|
||||
:klass => Issue,
|
||||
:label => :label_issue}
|
||||
}
|
||||
|
||||
# Add time entry custom fields
|
||||
custom_fields = TimeEntryCustomField.all
|
||||
# Add project custom fields
|
||||
custom_fields += ProjectCustomField.all
|
||||
# Add issue custom fields
|
||||
custom_fields += (@project.nil? ? IssueCustomField.for_all : @project.all_issue_custom_fields)
|
||||
# Add time entry activity custom fields
|
||||
custom_fields += TimeEntryActivityCustomField.all
|
||||
|
||||
# Add list and boolean custom fields as available criteria
|
||||
custom_fields.select {|cf| %w(list bool).include?(cf.field_format) && !cf.multiple?}.each do |cf|
|
||||
@available_criteria["cf_#{cf.id}"] = {:sql => cf.group_statement,
|
||||
:joins => cf.join_for_order_statement,
|
||||
:format => cf.field_format,
|
||||
:custom_field => cf,
|
||||
:label => cf.name}
|
||||
end
|
||||
|
||||
@available_criteria
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
35
lib/redmine/helpers/url.rb
Normal file
35
lib/redmine/helpers/url.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'uri'
|
||||
|
||||
module Redmine
|
||||
module Helpers
|
||||
module URL
|
||||
def uri_with_safe_scheme?(uri, schemes = ['http', 'https', 'ftp', 'mailto', nil])
|
||||
# URLs relative to the current document or document root (without a protocol
|
||||
# separator, should be harmless
|
||||
return true unless uri.to_s.include? ":"
|
||||
|
||||
# Other URLs need to be parsed
|
||||
schemes.include? URI.parse(uri).scheme
|
||||
rescue URI::InvalidURIError
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
101
lib/redmine/hook.rb
Normal file
101
lib/redmine/hook.rb
Normal file
|
@ -0,0 +1,101 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Hook
|
||||
@@listener_classes = []
|
||||
@@listeners = nil
|
||||
@@hook_listeners = {}
|
||||
|
||||
class << self
|
||||
# Adds a listener class.
|
||||
# Automatically called when a class inherits from Redmine::Hook::Listener.
|
||||
def add_listener(klass)
|
||||
raise "Hooks must include Singleton module." unless klass.included_modules.include?(Singleton)
|
||||
@@listener_classes << klass
|
||||
clear_listeners_instances
|
||||
end
|
||||
|
||||
# Returns all the listener instances.
|
||||
def listeners
|
||||
@@listeners ||= @@listener_classes.collect {|listener| listener.instance}
|
||||
end
|
||||
|
||||
# Returns the listener instances for the given hook.
|
||||
def hook_listeners(hook)
|
||||
@@hook_listeners[hook] ||= listeners.select {|listener| listener.respond_to?(hook)}
|
||||
end
|
||||
|
||||
# Clears all the listeners.
|
||||
def clear_listeners
|
||||
@@listener_classes = []
|
||||
clear_listeners_instances
|
||||
end
|
||||
|
||||
# Clears all the listeners instances.
|
||||
def clear_listeners_instances
|
||||
@@listeners = nil
|
||||
@@hook_listeners = {}
|
||||
end
|
||||
|
||||
# Calls a hook.
|
||||
# Returns the listeners response.
|
||||
def call_hook(hook, context={})
|
||||
[].tap do |response|
|
||||
hls = hook_listeners(hook)
|
||||
if hls.any?
|
||||
hls.each {|listener| response << listener.send(hook, context)}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper module included in ApplicationHelper and ActionController so that
|
||||
# hooks can be called in views like this:
|
||||
#
|
||||
# <%= call_hook(:some_hook) %>
|
||||
# <%= call_hook(:another_hook, :foo => 'bar') %>
|
||||
#
|
||||
# Or in controllers like:
|
||||
# call_hook(:some_hook)
|
||||
# call_hook(:another_hook, :foo => 'bar')
|
||||
#
|
||||
# Hooks added to views will be concatenated into a string. Hooks added to
|
||||
# controllers will return an array of results.
|
||||
#
|
||||
# Several objects are automatically added to the call context:
|
||||
#
|
||||
# * project => current project
|
||||
# * request => Request instance
|
||||
# * controller => current Controller instance
|
||||
# * hook_caller => object that called the hook
|
||||
#
|
||||
module Helper
|
||||
def call_hook(hook, context={})
|
||||
if is_a?(ActionController::Base)
|
||||
default_context = {:controller => self, :project => @project, :request => request, :hook_caller => self}
|
||||
Redmine::Hook.call_hook(hook, default_context.merge(context))
|
||||
else
|
||||
default_context = { :project => @project, :hook_caller => self }
|
||||
default_context[:controller] = controller if respond_to?(:controller)
|
||||
default_context[:request] = request if respond_to?(:request)
|
||||
Redmine::Hook.call_hook(hook, default_context.merge(context)).join(' ').html_safe
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
32
lib/redmine/hook/listener.rb
Normal file
32
lib/redmine/hook/listener.rb
Normal file
|
@ -0,0 +1,32 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Hook
|
||||
# Base class for hook listeners.
|
||||
class Listener
|
||||
include Singleton
|
||||
include Redmine::I18n
|
||||
|
||||
# Registers the listener
|
||||
def self.inherited(child)
|
||||
Redmine::Hook.add_listener(child)
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
78
lib/redmine/hook/view_listener.rb
Normal file
78
lib/redmine/hook/view_listener.rb
Normal file
|
@ -0,0 +1,78 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Hook
|
||||
# Listener class used for views hooks.
|
||||
# Listeners that inherit this class will include various helpers by default.
|
||||
class ViewListener < Listener
|
||||
include ERB::Util
|
||||
include ActionView::Helpers::TagHelper
|
||||
include ActionView::Helpers::FormHelper
|
||||
include ActionView::Helpers::FormTagHelper
|
||||
include ActionView::Helpers::FormOptionsHelper
|
||||
include ActionView::Helpers::JavaScriptHelper
|
||||
include ActionView::Helpers::NumberHelper
|
||||
include ActionView::Helpers::UrlHelper
|
||||
include ActionView::Helpers::AssetTagHelper
|
||||
include ActionView::Helpers::TextHelper
|
||||
include Rails.application.routes.url_helpers
|
||||
include ApplicationHelper
|
||||
|
||||
# Default to creating links using only the path. Subclasses can
|
||||
# change this default as needed
|
||||
def self.default_url_options
|
||||
{:only_path => true, :script_name => Redmine::Utils.relative_url_root}
|
||||
end
|
||||
|
||||
# Helper method to directly render using the context,
|
||||
# render_options must be valid #render options.
|
||||
#
|
||||
# class MyHook < Redmine::Hook::ViewListener
|
||||
# render_on :view_issues_show_details_bottom, :partial => "show_more_data"
|
||||
# end
|
||||
#
|
||||
# class MultipleHook < Redmine::Hook::ViewListener
|
||||
# render_on :view_issues_show_details_bottom,
|
||||
# {:partial => "show_more_data"},
|
||||
# {:partial => "show_even_more_data"}
|
||||
# end
|
||||
#
|
||||
def self.render_on(hook, *render_options)
|
||||
define_method hook do |context|
|
||||
render_options.map do |options|
|
||||
if context[:hook_caller].respond_to?(:render)
|
||||
context[:hook_caller].send(:render, {:locals => context}.merge(options))
|
||||
elsif context[:controller].is_a?(ActionController::Base)
|
||||
context[:controller].send(:render_to_string, {:locals => context}.merge(options))
|
||||
else
|
||||
raise "Cannot render #{self.name} hook from #{context[:hook_caller].class.name}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def controller
|
||||
nil
|
||||
end
|
||||
|
||||
def config
|
||||
ActionController::Base.config
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
217
lib/redmine/i18n.rb
Normal file
217
lib/redmine/i18n.rb
Normal file
|
@ -0,0 +1,217 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module I18n
|
||||
def self.included(base)
|
||||
base.extend Redmine::I18n
|
||||
end
|
||||
|
||||
def l(*args)
|
||||
case args.size
|
||||
when 1
|
||||
::I18n.t(*args)
|
||||
when 2
|
||||
if args.last.is_a?(Hash)
|
||||
::I18n.t(*args)
|
||||
elsif args.last.is_a?(String)
|
||||
::I18n.t(args.first, :value => args.last)
|
||||
else
|
||||
::I18n.t(args.first, :count => args.last)
|
||||
end
|
||||
else
|
||||
raise "Translation string with multiple values: #{args.first}"
|
||||
end
|
||||
end
|
||||
|
||||
def l_or_humanize(s, options={})
|
||||
k = "#{options[:prefix]}#{s}".to_sym
|
||||
::I18n.t(k, :default => s.to_s.humanize)
|
||||
end
|
||||
|
||||
def l_hours(hours)
|
||||
hours = hours.to_f
|
||||
l((hours < 2.0 ? :label_f_hour : :label_f_hour_plural), :value => format_hours(hours))
|
||||
end
|
||||
|
||||
def l_hours_short(hours)
|
||||
l(:label_f_hour_short, :value => format_hours(hours.to_f))
|
||||
end
|
||||
|
||||
def ll(lang, str, arg=nil)
|
||||
options = arg.is_a?(Hash) ? arg : {:value => arg}
|
||||
locale = lang.to_s.gsub(%r{(.+)\-(.+)$}) { "#{$1}-#{$2.upcase}" }
|
||||
::I18n.t(str.to_s, options.merge(:locale => locale))
|
||||
end
|
||||
|
||||
# Localizes the given args with user's language
|
||||
def lu(user, *args)
|
||||
lang = user.try(:language).presence || Setting.default_language
|
||||
ll(lang, *args)
|
||||
end
|
||||
|
||||
def format_date(date)
|
||||
return nil unless date
|
||||
options = {}
|
||||
options[:format] = Setting.date_format unless Setting.date_format.blank?
|
||||
::I18n.l(date.to_date, options)
|
||||
end
|
||||
|
||||
def format_time(time, include_date=true, user=nil)
|
||||
return nil unless time
|
||||
user ||= User.current
|
||||
options = {}
|
||||
options[:format] = (Setting.time_format.blank? ? :time : Setting.time_format)
|
||||
time = time.to_time if time.is_a?(String)
|
||||
zone = user.time_zone
|
||||
local = zone ? time.in_time_zone(zone) : (time.utc? ? time.localtime : time)
|
||||
(include_date ? "#{format_date(local)} " : "") + ::I18n.l(local, options)
|
||||
end
|
||||
|
||||
def format_hours(hours)
|
||||
return "" if hours.blank?
|
||||
|
||||
if Setting.timespan_format == 'minutes'
|
||||
h = hours.floor
|
||||
m = ((hours - h) * 60).round
|
||||
"%d:%02d" % [ h, m ]
|
||||
else
|
||||
"%.2f" % hours.to_f
|
||||
end
|
||||
end
|
||||
|
||||
def day_name(day)
|
||||
::I18n.t('date.day_names')[day % 7]
|
||||
end
|
||||
|
||||
def day_letter(day)
|
||||
::I18n.t('date.abbr_day_names')[day % 7].first
|
||||
end
|
||||
|
||||
def month_name(month)
|
||||
::I18n.t('date.month_names')[month]
|
||||
end
|
||||
|
||||
def valid_languages
|
||||
::I18n.available_locales
|
||||
end
|
||||
|
||||
# Returns an array of languages names and code sorted by names, example:
|
||||
# [["Deutsch", "de"], ["English", "en"] ...]
|
||||
#
|
||||
# The result is cached to prevent from loading all translations files
|
||||
# unless :cache => false option is given
|
||||
def languages_options(options={})
|
||||
options = if options[:cache] == false
|
||||
valid_languages.
|
||||
select {|locale| ::I18n.exists?(:general_lang_name, locale)}.
|
||||
map {|lang| [ll(lang.to_s, :general_lang_name), lang.to_s]}.
|
||||
sort {|x,y| x.first <=> y.first }
|
||||
else
|
||||
ActionController::Base.cache_store.fetch "i18n/languages_options/#{Redmine::VERSION}" do
|
||||
languages_options :cache => false
|
||||
end
|
||||
end
|
||||
options.map {|name, lang| [name.force_encoding("UTF-8"), lang.force_encoding("UTF-8")]}
|
||||
end
|
||||
|
||||
def find_language(lang)
|
||||
@@languages_lookup ||= valid_languages.inject({}) {|k, v| k[v.to_s.downcase] = v; k }
|
||||
@@languages_lookup[lang.to_s.downcase]
|
||||
end
|
||||
|
||||
def set_language_if_valid(lang)
|
||||
if l = find_language(lang)
|
||||
::I18n.locale = l
|
||||
end
|
||||
end
|
||||
|
||||
def current_language
|
||||
::I18n.locale
|
||||
end
|
||||
|
||||
# Custom backend based on I18n::Backend::Simple with the following changes:
|
||||
# * lazy loading of translation files
|
||||
# * available_locales are determined by looking at translation file names
|
||||
class Backend
|
||||
(class << self; self; end).class_eval { public :include }
|
||||
|
||||
module Implementation
|
||||
include ::I18n::Backend::Base
|
||||
include ::I18n::Backend::Pluralization
|
||||
|
||||
# Stores translations for the given locale in memory.
|
||||
# This uses a deep merge for the translations hash, so existing
|
||||
# translations will be overwritten by new ones only at the deepest
|
||||
# level of the hash.
|
||||
def store_translations(locale, data, options = {})
|
||||
locale = locale.to_sym
|
||||
translations[locale] ||= {}
|
||||
data = data.deep_symbolize_keys
|
||||
translations[locale].deep_merge!(data)
|
||||
end
|
||||
|
||||
# Get available locales from the translations filenames
|
||||
def available_locales
|
||||
@available_locales ||= ::I18n.load_path.map {|path| File.basename(path, '.*')}.uniq.sort.map(&:to_sym)
|
||||
end
|
||||
|
||||
# Clean up translations
|
||||
def reload!
|
||||
@translations = nil
|
||||
@available_locales = nil
|
||||
super
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def init_translations(locale)
|
||||
locale = locale.to_s
|
||||
paths = ::I18n.load_path.select {|path| File.basename(path, '.*') == locale}
|
||||
load_translations(paths)
|
||||
translations[locale] ||= {}
|
||||
end
|
||||
|
||||
def translations
|
||||
@translations ||= {}
|
||||
end
|
||||
|
||||
# Looks up a translation from the translations hash. Returns nil if
|
||||
# eiher key is nil, or locale, scope or key do not exist as a key in the
|
||||
# nested translations hash. Splits keys or scopes containing dots
|
||||
# into multiple keys, i.e. <tt>currency.format</tt> is regarded the same as
|
||||
# <tt>%w(currency format)</tt>.
|
||||
def lookup(locale, key, scope = [], options = {})
|
||||
init_translations(locale) unless translations.key?(locale)
|
||||
keys = ::I18n.normalize_keys(locale, key, scope, options[:separator])
|
||||
|
||||
keys.inject(translations) do |result, _key|
|
||||
_key = _key.to_sym
|
||||
return nil unless result.is_a?(Hash) && result.has_key?(_key)
|
||||
result = result[_key]
|
||||
result = resolve(locale, _key, result, options.merge(:scope => nil)) if result.is_a?(Symbol)
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
include Implementation
|
||||
# Adds fallback to default locale for untranslated strings
|
||||
include ::I18n::Backend::Fallbacks
|
||||
end
|
||||
end
|
||||
end
|
66
lib/redmine/imap.rb
Normal file
66
lib/redmine/imap.rb
Normal file
|
@ -0,0 +1,66 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'net/imap'
|
||||
|
||||
module Redmine
|
||||
module IMAP
|
||||
class << self
|
||||
def check(imap_options={}, options={})
|
||||
host = imap_options[:host] || '127.0.0.1'
|
||||
port = imap_options[:port] || '143'
|
||||
ssl = !imap_options[:ssl].nil?
|
||||
starttls = !imap_options[:starttls].nil?
|
||||
folder = imap_options[:folder] || 'INBOX'
|
||||
|
||||
imap = Net::IMAP.new(host, port, ssl)
|
||||
if starttls
|
||||
imap.starttls
|
||||
end
|
||||
imap.login(imap_options[:username], imap_options[:password]) unless imap_options[:username].nil?
|
||||
imap.select(folder)
|
||||
imap.uid_search(['NOT', 'SEEN']).each do |uid|
|
||||
msg = imap.uid_fetch(uid,'RFC822')[0].attr['RFC822']
|
||||
logger.debug "Receiving message #{uid}" if logger && logger.debug?
|
||||
if MailHandler.safe_receive(msg, options)
|
||||
logger.debug "Message #{uid} successfully received" if logger && logger.debug?
|
||||
if imap_options[:move_on_success]
|
||||
imap.uid_copy(uid, imap_options[:move_on_success])
|
||||
end
|
||||
imap.uid_store(uid, "+FLAGS", [:Seen, :Deleted])
|
||||
else
|
||||
logger.debug "Message #{uid} can not be processed" if logger && logger.debug?
|
||||
imap.uid_store(uid, "+FLAGS", [:Seen])
|
||||
if imap_options[:move_on_failure]
|
||||
imap.uid_copy(uid, imap_options[:move_on_failure])
|
||||
imap.uid_store(uid, "+FLAGS", [:Deleted])
|
||||
end
|
||||
end
|
||||
end
|
||||
imap.expunge
|
||||
imap.logout
|
||||
imap.disconnect
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def logger
|
||||
::Rails.logger
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
37
lib/redmine/info.rb
Normal file
37
lib/redmine/info.rb
Normal file
|
@ -0,0 +1,37 @@
|
|||
module Redmine
|
||||
module Info
|
||||
class << self
|
||||
def app_name; 'Redmine' end
|
||||
def url; 'https://www.redmine.org/' end
|
||||
def help_url; 'https://www.redmine.org/guide' end
|
||||
def versioned_name; "#{app_name} #{Redmine::VERSION}" end
|
||||
|
||||
def environment
|
||||
s = "Environment:\n"
|
||||
s << [
|
||||
["Redmine version", Redmine::VERSION],
|
||||
["Ruby version", "#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE}) [#{RUBY_PLATFORM}]"],
|
||||
["Rails version", Rails::VERSION::STRING],
|
||||
["Environment", Rails.env],
|
||||
["Database adapter", ActiveRecord::Base.connection.adapter_name]
|
||||
].map {|info| " %-30s %s" % info}.join("\n") + "\n"
|
||||
|
||||
s << "SCM:\n"
|
||||
Redmine::Scm::Base.all.each do |scm|
|
||||
scm_class = "Repository::#{scm}".constantize
|
||||
if scm_class.scm_available
|
||||
s << " %-30s %s\n" % [scm, scm_class.scm_version_string]
|
||||
end
|
||||
end
|
||||
|
||||
s << "Redmine plugins:\n"
|
||||
plugins = Redmine::Plugin.all
|
||||
if plugins.any?
|
||||
s << plugins.map {|plugin| " %-30s %s" % [plugin.id.to_s, plugin.version.to_s]}.join("\n")
|
||||
else
|
||||
s << " no plugin installed"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
491
lib/redmine/menu_manager.rb
Normal file
491
lib/redmine/menu_manager.rb
Normal file
|
@ -0,0 +1,491 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module MenuManager
|
||||
class MenuError < StandardError #:nodoc:
|
||||
end
|
||||
|
||||
module MenuController
|
||||
def self.included(base)
|
||||
base.class_attribute :main_menu
|
||||
base.main_menu = true
|
||||
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
@@menu_items = Hash.new {|hash, key| hash[key] = {:default => key, :actions => {}}}
|
||||
mattr_accessor :menu_items
|
||||
|
||||
# Set the menu item name for a controller or specific actions
|
||||
# Examples:
|
||||
# * menu_item :tickets # => sets the menu name to :tickets for the whole controller
|
||||
# * menu_item :tickets, :only => :list # => sets the menu name to :tickets for the 'list' action only
|
||||
# * menu_item :tickets, :only => [:list, :show] # => sets the menu name to :tickets for 2 actions only
|
||||
#
|
||||
# The default menu item name for a controller is controller_name by default
|
||||
# Eg. the default menu item name for ProjectsController is :projects
|
||||
def menu_item(id, options = {})
|
||||
if actions = options[:only]
|
||||
actions = [] << actions unless actions.is_a?(Array)
|
||||
actions.each {|a| menu_items[controller_name.to_sym][:actions][a.to_sym] = id}
|
||||
else
|
||||
menu_items[controller_name.to_sym][:default] = id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def menu_items
|
||||
self.class.menu_items
|
||||
end
|
||||
|
||||
def current_menu(project)
|
||||
if project && !project.new_record?
|
||||
:project_menu
|
||||
elsif self.class.main_menu
|
||||
:application_menu
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the menu item name according to the current action
|
||||
def current_menu_item
|
||||
@current_menu_item ||= menu_items[controller_name.to_sym][:actions][action_name.to_sym] ||
|
||||
menu_items[controller_name.to_sym][:default]
|
||||
end
|
||||
|
||||
# Redirects user to the menu item
|
||||
# Returns false if user is not authorized
|
||||
def redirect_to_menu_item(name)
|
||||
redirect_to_project_menu_item(nil, name)
|
||||
end
|
||||
|
||||
# Redirects user to the menu item of the given project
|
||||
# Returns false if user is not authorized
|
||||
def redirect_to_project_menu_item(project, name)
|
||||
menu = project.nil? ? :application_menu : :project_menu
|
||||
item = Redmine::MenuManager.items(menu).detect {|i| i.name.to_s == name.to_s}
|
||||
if item && item.allowed?(User.current, project)
|
||||
url = item.url
|
||||
url = {item.param => project}.merge(url) if project
|
||||
redirect_to url
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
module MenuHelper
|
||||
# Returns the current menu item name
|
||||
def current_menu_item
|
||||
controller.current_menu_item
|
||||
end
|
||||
|
||||
# Renders the application main menu
|
||||
def render_main_menu(project)
|
||||
if menu_name = controller.current_menu(project)
|
||||
render_menu(menu_name, project)
|
||||
end
|
||||
end
|
||||
|
||||
def display_main_menu?(project)
|
||||
menu_name = controller.current_menu(project)
|
||||
menu_name.present? && Redmine::MenuManager.items(menu_name).children.present?
|
||||
end
|
||||
|
||||
def render_menu(menu, project=nil)
|
||||
links = []
|
||||
menu_items_for(menu, project) do |node|
|
||||
links << render_menu_node(node, project)
|
||||
end
|
||||
links.empty? ? nil : content_tag('ul', links.join.html_safe)
|
||||
end
|
||||
|
||||
def render_menu_node(node, project=nil)
|
||||
if node.children.present? || !node.child_menus.nil?
|
||||
return render_menu_node_with_children(node, project)
|
||||
else
|
||||
caption, url, selected = extract_node_details(node, project)
|
||||
return content_tag('li',
|
||||
render_single_menu_node(node, caption, url, selected))
|
||||
end
|
||||
end
|
||||
|
||||
def render_menu_node_with_children(node, project=nil)
|
||||
caption, url, selected = extract_node_details(node, project)
|
||||
|
||||
html = [].tap do |html|
|
||||
html << '<li>'
|
||||
# Parent
|
||||
html << render_single_menu_node(node, caption, url, selected)
|
||||
|
||||
# Standard children
|
||||
standard_children_list = "".html_safe.tap do |child_html|
|
||||
node.children.each do |child|
|
||||
child_html << render_menu_node(child, project) if allowed_node?(child, User.current, project)
|
||||
end
|
||||
end
|
||||
|
||||
html << content_tag(:ul, standard_children_list, :class => 'menu-children') unless standard_children_list.empty?
|
||||
|
||||
# Unattached children
|
||||
unattached_children_list = render_unattached_children_menu(node, project)
|
||||
html << content_tag(:ul, unattached_children_list, :class => 'menu-children unattached') unless unattached_children_list.blank?
|
||||
|
||||
html << '</li>'
|
||||
end
|
||||
return html.join("\n").html_safe
|
||||
end
|
||||
|
||||
# Returns a list of unattached children menu items
|
||||
def render_unattached_children_menu(node, project)
|
||||
return nil unless node.child_menus
|
||||
|
||||
"".html_safe.tap do |child_html|
|
||||
unattached_children = node.child_menus.call(project)
|
||||
# Tree nodes support #each so we need to do object detection
|
||||
if unattached_children.is_a? Array
|
||||
unattached_children.each do |child|
|
||||
child_html << content_tag(:li, render_unattached_menu_item(child, project)) if allowed_node?(child, User.current, project)
|
||||
end
|
||||
else
|
||||
raise MenuError, ":child_menus must be an array of MenuItems"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def render_single_menu_node(item, caption, url, selected)
|
||||
options = item.html_options(:selected => selected)
|
||||
|
||||
# virtual nodes are only there for their children to be displayed in the menu
|
||||
# and should not do anything on click, except if otherwise defined elsewhere
|
||||
if url.blank?
|
||||
url = '#'
|
||||
options.reverse_merge!(:onclick => 'return false;')
|
||||
end
|
||||
link_to(h(caption), url, options)
|
||||
end
|
||||
|
||||
def render_unattached_menu_item(menu_item, project)
|
||||
raise MenuError, ":child_menus must be an array of MenuItems" unless menu_item.is_a? MenuItem
|
||||
|
||||
if menu_item.allowed?(User.current, project)
|
||||
link_to(menu_item.caption, menu_item.url, menu_item.html_options)
|
||||
end
|
||||
end
|
||||
|
||||
def menu_items_for(menu, project=nil)
|
||||
items = []
|
||||
Redmine::MenuManager.items(menu).root.children.each do |node|
|
||||
if node.allowed?(User.current, project)
|
||||
if block_given?
|
||||
yield node
|
||||
else
|
||||
items << node # TODO: not used?
|
||||
end
|
||||
end
|
||||
end
|
||||
return block_given? ? nil : items
|
||||
end
|
||||
|
||||
def extract_node_details(node, project=nil)
|
||||
item = node
|
||||
url = case item.url
|
||||
when Hash
|
||||
project.nil? ? item.url : {item.param => project}.merge(item.url)
|
||||
when Symbol
|
||||
if project
|
||||
send(item.url, project)
|
||||
else
|
||||
send(item.url)
|
||||
end
|
||||
else
|
||||
item.url
|
||||
end
|
||||
caption = item.caption(project)
|
||||
return [caption, url, (current_menu_item == item.name)]
|
||||
end
|
||||
|
||||
# See MenuItem#allowed?
|
||||
def allowed_node?(node, user, project)
|
||||
raise MenuError, ":child_menus must be an array of MenuItems" unless node.is_a? MenuItem
|
||||
node.allowed?(user, project)
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def map(menu_name)
|
||||
@items ||= {}
|
||||
mapper = Mapper.new(menu_name.to_sym, @items)
|
||||
if block_given?
|
||||
yield mapper
|
||||
else
|
||||
mapper
|
||||
end
|
||||
end
|
||||
|
||||
def items(menu_name)
|
||||
@items[menu_name.to_sym] || MenuNode.new(:root, {})
|
||||
end
|
||||
end
|
||||
|
||||
class Mapper
|
||||
attr_reader :menu, :menu_items
|
||||
|
||||
def initialize(menu, items)
|
||||
items[menu] ||= MenuNode.new(:root, {})
|
||||
@menu = menu
|
||||
@menu_items = items[menu]
|
||||
end
|
||||
|
||||
# Adds an item at the end of the menu. Available options:
|
||||
# * param: the parameter name that is used for the project id (default is :id)
|
||||
# * if: a Proc that is called before rendering the item, the item is displayed only if it returns true
|
||||
# * caption that can be:
|
||||
# * a localized string Symbol
|
||||
# * a String
|
||||
# * a Proc that can take the project as argument
|
||||
# * before, after: specify where the menu item should be inserted (eg. :after => :activity)
|
||||
# * parent: menu item will be added as a child of another named menu (eg. :parent => :issues)
|
||||
# * children: a Proc that is called before rendering the item. The Proc should return an array of MenuItems, which will be added as children to this item.
|
||||
# eg. :children => Proc.new {|project| [Redmine::MenuManager::MenuItem.new(...)] }
|
||||
# * last: menu item will stay at the end (eg. :last => true)
|
||||
# * html_options: a hash of html options that are passed to link_to
|
||||
def push(name, url, options={})
|
||||
options = options.dup
|
||||
|
||||
if options[:parent]
|
||||
subtree = self.find(options[:parent])
|
||||
if subtree
|
||||
target_root = subtree
|
||||
else
|
||||
target_root = @menu_items.root
|
||||
end
|
||||
|
||||
else
|
||||
target_root = @menu_items.root
|
||||
end
|
||||
|
||||
# menu item position
|
||||
if first = options.delete(:first)
|
||||
target_root.prepend(MenuItem.new(name, url, options))
|
||||
elsif before = options.delete(:before)
|
||||
|
||||
if exists?(before)
|
||||
target_root.add_at(MenuItem.new(name, url, options), position_of(before))
|
||||
else
|
||||
target_root.add(MenuItem.new(name, url, options))
|
||||
end
|
||||
|
||||
elsif after = options.delete(:after)
|
||||
|
||||
if exists?(after)
|
||||
target_root.add_at(MenuItem.new(name, url, options), position_of(after) + 1)
|
||||
else
|
||||
target_root.add(MenuItem.new(name, url, options))
|
||||
end
|
||||
|
||||
elsif options[:last] # don't delete, needs to be stored
|
||||
target_root.add_last(MenuItem.new(name, url, options))
|
||||
else
|
||||
target_root.add(MenuItem.new(name, url, options))
|
||||
end
|
||||
end
|
||||
|
||||
# Removes a menu item
|
||||
def delete(name)
|
||||
if found = self.find(name)
|
||||
@menu_items.remove!(found)
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a menu item exists
|
||||
def exists?(name)
|
||||
@menu_items.any? {|node| node.name == name}
|
||||
end
|
||||
|
||||
def find(name)
|
||||
@menu_items.find {|node| node.name == name}
|
||||
end
|
||||
|
||||
def position_of(name)
|
||||
@menu_items.each do |node|
|
||||
if node.name == name
|
||||
return node.position
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class MenuNode
|
||||
include Enumerable
|
||||
attr_accessor :parent
|
||||
attr_reader :last_items_count, :name
|
||||
|
||||
def initialize(name, content = nil)
|
||||
@name = name
|
||||
@children = []
|
||||
@last_items_count = 0
|
||||
end
|
||||
|
||||
def children
|
||||
if block_given?
|
||||
@children.each {|child| yield child}
|
||||
else
|
||||
@children
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the number of descendants + 1
|
||||
def size
|
||||
@children.inject(1) {|sum, node| sum + node.size}
|
||||
end
|
||||
|
||||
def each &block
|
||||
yield self
|
||||
children { |child| child.each(&block) }
|
||||
end
|
||||
|
||||
# Adds a child at first position
|
||||
def prepend(child)
|
||||
add_at(child, 0)
|
||||
end
|
||||
|
||||
# Adds a child at given position
|
||||
def add_at(child, position)
|
||||
raise "Child already added" if find {|node| node.name == child.name}
|
||||
|
||||
@children = @children.insert(position, child)
|
||||
child.parent = self
|
||||
child
|
||||
end
|
||||
|
||||
# Adds a child as last child
|
||||
def add_last(child)
|
||||
add_at(child, -1)
|
||||
@last_items_count += 1
|
||||
child
|
||||
end
|
||||
|
||||
# Adds a child
|
||||
def add(child)
|
||||
position = @children.size - @last_items_count
|
||||
add_at(child, position)
|
||||
end
|
||||
alias :<< :add
|
||||
|
||||
# Removes a child
|
||||
def remove!(child)
|
||||
@children.delete(child)
|
||||
@last_items_count -= +1 if child && child.last
|
||||
child.parent = nil
|
||||
child
|
||||
end
|
||||
|
||||
# Returns the position for this node in it's parent
|
||||
def position
|
||||
self.parent.children.index(self)
|
||||
end
|
||||
|
||||
# Returns the root for this node
|
||||
def root
|
||||
root = self
|
||||
root = root.parent while root.parent
|
||||
root
|
||||
end
|
||||
end
|
||||
|
||||
class MenuItem < MenuNode
|
||||
include Redmine::I18n
|
||||
attr_reader :name, :url, :param, :condition, :parent, :child_menus, :last, :permission
|
||||
|
||||
def initialize(name, url, options={})
|
||||
raise ArgumentError, "Invalid option :if for menu item '#{name}'" if options[:if] && !options[:if].respond_to?(:call)
|
||||
raise ArgumentError, "Invalid option :html for menu item '#{name}'" if options[:html] && !options[:html].is_a?(Hash)
|
||||
raise ArgumentError, "Cannot set the :parent to be the same as this item" if options[:parent] == name.to_sym
|
||||
raise ArgumentError, "Invalid option :children for menu item '#{name}'" if options[:children] && !options[:children].respond_to?(:call)
|
||||
@name = name
|
||||
@url = url
|
||||
@condition = options[:if]
|
||||
@permission = options[:permission]
|
||||
@permission ||= false if options.key?(:permission)
|
||||
@param = options[:param] || :id
|
||||
@caption = options[:caption]
|
||||
@html_options = options[:html] || {}
|
||||
# Adds a unique class to each menu item based on its name
|
||||
@html_options[:class] = [@html_options[:class], @name.to_s.dasherize].compact.join(' ')
|
||||
@parent = options[:parent]
|
||||
@child_menus = options[:children]
|
||||
@last = options[:last] || false
|
||||
super @name.to_sym
|
||||
end
|
||||
|
||||
def caption(project=nil)
|
||||
if @caption.is_a?(Proc)
|
||||
c = @caption.call(project).to_s
|
||||
c = @name.to_s.humanize if c.blank?
|
||||
c
|
||||
else
|
||||
if @caption.nil?
|
||||
l_or_humanize(name, :prefix => 'label_')
|
||||
else
|
||||
@caption.is_a?(Symbol) ? l(@caption) : @caption
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def html_options(options={})
|
||||
if options[:selected]
|
||||
o = @html_options.dup
|
||||
o[:class] += ' selected'
|
||||
o
|
||||
else
|
||||
@html_options
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if a user is allowed to access the menu item by:
|
||||
#
|
||||
# * Checking the permission or the url target (project only)
|
||||
# * Checking the conditions of the item
|
||||
def allowed?(user, project)
|
||||
if url.blank?
|
||||
# this is a virtual node that is only there for its children to be diplayed in the menu
|
||||
# it is considered an allowed node if at least one of the children is allowed
|
||||
all_children = children
|
||||
all_children += child_menus.call(project) if child_menus
|
||||
return false unless all_children.detect{|child| child.allowed?(user, project) }
|
||||
elsif user && project
|
||||
if permission
|
||||
unless user.allowed_to?(permission, project)
|
||||
return false
|
||||
end
|
||||
elsif permission.nil? && url.is_a?(Hash)
|
||||
unless user.allowed_to?(url, project)
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
if condition && !condition.call(project)
|
||||
# Condition that doesn't pass
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
94
lib/redmine/mime_type.rb
Normal file
94
lib/redmine/mime_type.rb
Normal file
|
@ -0,0 +1,94 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'mime/types'
|
||||
|
||||
module Redmine
|
||||
module MimeType
|
||||
|
||||
MIME_TYPES = {
|
||||
'text/plain' => 'txt,tpl,properties,patch,diff,ini,readme,install,upgrade,sql',
|
||||
'text/css' => 'css',
|
||||
'text/html' => 'html,htm,xhtml',
|
||||
'text/jsp' => 'jsp',
|
||||
'text/x-c' => 'c,cpp,cc,h,hh',
|
||||
'text/x-csharp' => 'cs',
|
||||
'text/x-java' => 'java',
|
||||
'text/x-html-template' => 'rhtml',
|
||||
'text/x-perl' => 'pl,pm',
|
||||
'text/x-php' => 'php,php3,php4,php5',
|
||||
'text/x-python' => 'py',
|
||||
'text/x-ruby' => 'rb,rbw,ruby,rake,erb',
|
||||
'text/x-csh' => 'csh',
|
||||
'text/x-sh' => 'sh',
|
||||
'text/xml' => 'xml,xsd,mxml',
|
||||
'text/yaml' => 'yml,yaml',
|
||||
'text/csv' => 'csv',
|
||||
'text/x-po' => 'po',
|
||||
'image/gif' => 'gif',
|
||||
'image/jpeg' => 'jpg,jpeg,jpe',
|
||||
'image/png' => 'png',
|
||||
'image/tiff' => 'tiff,tif',
|
||||
'image/x-ms-bmp' => 'bmp',
|
||||
'application/javascript' => 'js',
|
||||
'application/pdf' => 'pdf',
|
||||
}.freeze
|
||||
|
||||
EXTENSIONS = MIME_TYPES.inject({}) do |map, (type, exts)|
|
||||
exts.split(',').each {|ext| map[ext.strip] = type}
|
||||
map
|
||||
end
|
||||
|
||||
# returns all full mime types for a given (top level) type
|
||||
def self.by_type(type)
|
||||
MIME_TYPES.keys.select{|m| m.start_with? "#{type}/"}
|
||||
end
|
||||
|
||||
# returns mime type for name or nil if unknown
|
||||
def self.of(name)
|
||||
return nil unless name.present?
|
||||
if m = name.to_s.match(/(^|\.)([^\.]+)$/)
|
||||
extension = m[2].downcase
|
||||
@known_types ||= Hash.new do |h, ext|
|
||||
type = EXTENSIONS[ext]
|
||||
type ||= MIME::Types.type_for(ext).first.to_s.presence
|
||||
h[ext] = type
|
||||
end
|
||||
@known_types[extension]
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the css class associated to
|
||||
# the mime type of name
|
||||
def self.css_class_of(name)
|
||||
mime = of(name)
|
||||
mime && mime.gsub('/', '-')
|
||||
end
|
||||
|
||||
def self.main_mimetype_of(name)
|
||||
mimetype = of(name)
|
||||
mimetype.split('/').first if mimetype
|
||||
end
|
||||
|
||||
# return true if mime-type for name is type/*
|
||||
# otherwise false
|
||||
def self.is_type?(type, name)
|
||||
main_mimetype = main_mimetype_of(name)
|
||||
type.to_s == main_mimetype
|
||||
end
|
||||
end
|
||||
end
|
91
lib/redmine/my_page.rb
Normal file
91
lib/redmine/my_page.rb
Normal file
|
@ -0,0 +1,91 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module MyPage
|
||||
include Redmine::I18n
|
||||
|
||||
CORE_GROUPS = ['top', 'left', 'right']
|
||||
|
||||
CORE_BLOCKS = {
|
||||
'issuesassignedtome' => {:label => :label_assigned_to_me_issues},
|
||||
'issuesreportedbyme' => {:label => :label_reported_issues},
|
||||
'issueswatched' => {:label => :label_watched_issues},
|
||||
'issuequery' => {:label => :label_issue_plural, :max_occurs => 3},
|
||||
'news' => {:label => :label_news_latest},
|
||||
'calendar' => {:label => :label_calendar},
|
||||
'documents' => {:label => :label_document_plural},
|
||||
'timelog' => {:label => :label_spent_time}
|
||||
}
|
||||
|
||||
def self.groups
|
||||
CORE_GROUPS.dup.freeze
|
||||
end
|
||||
|
||||
# Returns the available blocks
|
||||
def self.blocks
|
||||
CORE_BLOCKS.merge(additional_blocks).freeze
|
||||
end
|
||||
|
||||
def self.block_options(blocks_in_use=[])
|
||||
options = []
|
||||
blocks.each do |block, block_options|
|
||||
indexes = blocks_in_use.map {|n|
|
||||
if n =~ /\A#{block}(__(\d+))?\z/
|
||||
$2.to_i
|
||||
end
|
||||
}.compact
|
||||
|
||||
occurs = indexes.size
|
||||
block_id = indexes.any? ? "#{block}__#{indexes.max + 1}" : block
|
||||
disabled = (occurs >= (Redmine::MyPage.blocks[block][:max_occurs] || 1))
|
||||
block_id = nil if disabled
|
||||
|
||||
label = block_options[:label]
|
||||
options << [l("my.blocks.#{label}", :default => [label, label.to_s.humanize]), block_id]
|
||||
end
|
||||
options
|
||||
end
|
||||
|
||||
def self.valid_block?(block, blocks_in_use=[])
|
||||
block.present? && block_options(blocks_in_use).map(&:last).include?(block)
|
||||
end
|
||||
|
||||
def self.find_block(block)
|
||||
block.to_s =~ /\A(.*?)(__\d+)?\z/
|
||||
name = $1
|
||||
blocks.has_key?(name) ? blocks[name].merge(:name => name) : nil
|
||||
end
|
||||
|
||||
# Returns the additional blocks that are defined by plugin partials
|
||||
def self.additional_blocks
|
||||
@@additional_blocks ||= Dir.glob("#{Redmine::Plugin.directory}/*/app/views/my/blocks/_*.{rhtml,erb}").inject({}) do |h,file|
|
||||
name = File.basename(file).split('.').first.gsub(/^_/, '')
|
||||
h[name] = {:label => name.to_sym, :partial => "my/blocks/#{name}"}
|
||||
h
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the default layout for My Page
|
||||
def self.default_layout
|
||||
{
|
||||
'left' => ['issuesassignedtome'],
|
||||
'right' => ['issuesreportedbyme']
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
210
lib/redmine/nested_set/issue_nested_set.rb
Normal file
210
lib/redmine/nested_set/issue_nested_set.rb
Normal file
|
@ -0,0 +1,210 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module NestedSet
|
||||
module IssueNestedSet
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
belongs_to :parent, :class_name => self.name
|
||||
|
||||
before_create :add_to_nested_set, :if => lambda {|issue| issue.parent.present?}
|
||||
after_create :add_as_root, :if => lambda {|issue| issue.parent.blank?}
|
||||
before_update :handle_parent_change, :if => lambda {|issue| issue.parent_id_changed?}
|
||||
before_destroy :destroy_children
|
||||
end
|
||||
base.extend ClassMethods
|
||||
base.send :include, Redmine::NestedSet::Traversing
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def target_lft
|
||||
scope_for_max_rgt = self.class.where(:root_id => root_id).where(:parent_id => parent_id)
|
||||
if id
|
||||
scope_for_max_rgt = scope_for_max_rgt.where("id < ?", id)
|
||||
end
|
||||
max_rgt = scope_for_max_rgt.maximum(:rgt)
|
||||
if max_rgt
|
||||
max_rgt + 1
|
||||
elsif parent
|
||||
parent.lft + 1
|
||||
else
|
||||
1
|
||||
end
|
||||
end
|
||||
|
||||
def add_to_nested_set(lock=true)
|
||||
lock_nested_set if lock
|
||||
parent.send :reload_nested_set_values
|
||||
self.root_id = parent.root_id
|
||||
self.lft = target_lft
|
||||
self.rgt = lft + 1
|
||||
self.class.where(:root_id => root_id).where("lft >= ? OR rgt >= ?", lft, lft).update_all([
|
||||
"lft = CASE WHEN lft >= :lft THEN lft + 2 ELSE lft END, " +
|
||||
"rgt = CASE WHEN rgt >= :lft THEN rgt + 2 ELSE rgt END",
|
||||
{:lft => lft}
|
||||
])
|
||||
end
|
||||
|
||||
def add_as_root
|
||||
self.root_id = id
|
||||
self.lft = 1
|
||||
self.rgt = 2
|
||||
self.class.where(:id => id).update_all(:root_id => root_id, :lft => lft, :rgt => rgt)
|
||||
end
|
||||
|
||||
def handle_parent_change
|
||||
lock_nested_set
|
||||
reload_nested_set_values
|
||||
if parent_id_was
|
||||
remove_from_nested_set
|
||||
end
|
||||
if parent
|
||||
move_to_nested_set
|
||||
end
|
||||
reload_nested_set_values
|
||||
end
|
||||
|
||||
def move_to_nested_set
|
||||
if parent
|
||||
previous_root_id = root_id
|
||||
self.root_id = parent.root_id
|
||||
|
||||
lft_after_move = target_lft
|
||||
self.class.where(:root_id => parent.root_id).update_all([
|
||||
"lft = CASE WHEN lft >= :lft THEN lft + :shift ELSE lft END, " +
|
||||
"rgt = CASE WHEN rgt >= :lft THEN rgt + :shift ELSE rgt END",
|
||||
{:lft => lft_after_move, :shift => (rgt - lft + 1)}
|
||||
])
|
||||
|
||||
self.class.where(:root_id => previous_root_id).update_all([
|
||||
"root_id = :root_id, lft = lft + :shift, rgt = rgt + :shift",
|
||||
{:root_id => parent.root_id, :shift => lft_after_move - lft}
|
||||
])
|
||||
|
||||
self.lft, self.rgt = lft_after_move, (rgt - lft + lft_after_move)
|
||||
parent.send :reload_nested_set_values
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_nested_set
|
||||
self.class.where(:root_id => root_id).where("lft >= ? AND rgt <= ?", lft, rgt).
|
||||
update_all(["root_id = :id, lft = lft - :shift, rgt = rgt - :shift", {:id => id, :shift => lft - 1}])
|
||||
|
||||
self.class.where(:root_id => root_id).update_all([
|
||||
"lft = CASE WHEN lft >= :lft THEN lft - :shift ELSE lft END, " +
|
||||
"rgt = CASE WHEN rgt >= :lft THEN rgt - :shift ELSE rgt END",
|
||||
{:lft => lft, :shift => rgt - lft + 1}
|
||||
])
|
||||
self.root_id = id
|
||||
self.lft, self.rgt = 1, (rgt - lft + 1)
|
||||
end
|
||||
|
||||
def destroy_children
|
||||
unless @without_nested_set_update
|
||||
lock_nested_set
|
||||
reload_nested_set_values
|
||||
end
|
||||
children.each {|c| c.send :destroy_without_nested_set_update}
|
||||
reload
|
||||
unless @without_nested_set_update
|
||||
self.class.where(:root_id => root_id).where("lft > ? OR rgt > ?", lft, lft).update_all([
|
||||
"lft = CASE WHEN lft > :lft THEN lft - :shift ELSE lft END, " +
|
||||
"rgt = CASE WHEN rgt > :lft THEN rgt - :shift ELSE rgt END",
|
||||
{:lft => lft, :shift => rgt - lft + 1}
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
def destroy_without_nested_set_update
|
||||
@without_nested_set_update = true
|
||||
destroy
|
||||
end
|
||||
|
||||
def reload_nested_set_values
|
||||
self.root_id, self.lft, self.rgt = self.class.where(:id => id).pluck(:root_id, :lft, :rgt).first
|
||||
end
|
||||
|
||||
def save_nested_set_values
|
||||
self.class.where(:id => id).update_all(:root_id => root_id, :lft => lft, :rgt => rgt)
|
||||
end
|
||||
|
||||
def move_possible?(issue)
|
||||
new_record? || !is_or_is_ancestor_of?(issue)
|
||||
end
|
||||
|
||||
def lock_nested_set
|
||||
if self.class.connection.adapter_name =~ /sqlserver/i
|
||||
lock = "WITH (ROWLOCK HOLDLOCK UPDLOCK)"
|
||||
# Custom lock for SQLServer
|
||||
# This can be problematic if root_id or parent root_id changes
|
||||
# before locking
|
||||
sets_to_lock = [root_id, parent.try(:root_id)].compact.uniq
|
||||
self.class.reorder(:id).where(:root_id => sets_to_lock).lock(lock).ids
|
||||
else
|
||||
sets_to_lock = [id, parent_id].compact
|
||||
self.class.reorder(:id).where("root_id IN (SELECT root_id FROM #{self.class.table_name} WHERE id IN (?))", sets_to_lock).lock.ids
|
||||
end
|
||||
end
|
||||
|
||||
def nested_set_scope
|
||||
self.class.order(:lft).where(:root_id => root_id)
|
||||
end
|
||||
|
||||
def same_nested_set_scope?(issue)
|
||||
root_id == issue.root_id
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def rebuild_tree!
|
||||
transaction do
|
||||
reorder(:id).lock.ids
|
||||
update_all(:root_id => nil, :lft => nil, :rgt => nil)
|
||||
where(:parent_id => nil).update_all(["root_id = id, lft = ?, rgt = ?", 1, 2])
|
||||
roots_with_children = joins("JOIN #{table_name} parent ON parent.id = #{table_name}.parent_id AND parent.id = parent.root_id").distinct.pluck("parent.id")
|
||||
roots_with_children.each do |root_id|
|
||||
rebuild_nodes(root_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def rebuild_single_tree!(root_id)
|
||||
root = Issue.where(:parent_id => nil).find(root_id)
|
||||
transaction do
|
||||
where(root_id: root_id).reorder(:id).lock.ids
|
||||
where(root_id: root_id).update_all(:lft => nil, :rgt => nil)
|
||||
where(root_id: root_id, parent_id: nil).update_all(["lft = ?, rgt = ?", 1, 2])
|
||||
rebuild_nodes(root_id)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rebuild_nodes(parent_id = nil)
|
||||
nodes = where(:parent_id => parent_id, :rgt => nil, :lft => nil).order(:id).to_a
|
||||
|
||||
nodes.each do |node|
|
||||
node.send :add_to_nested_set, false
|
||||
node.send :save_nested_set_values
|
||||
rebuild_nodes node.id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
159
lib/redmine/nested_set/project_nested_set.rb
Normal file
159
lib/redmine/nested_set/project_nested_set.rb
Normal file
|
@ -0,0 +1,159 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module NestedSet
|
||||
module ProjectNestedSet
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
belongs_to :parent, :class_name => self.name
|
||||
|
||||
before_create :add_to_nested_set
|
||||
before_update :move_in_nested_set, :if => lambda {|project| project.parent_id_changed? || project.name_changed?}
|
||||
before_destroy :destroy_children
|
||||
end
|
||||
base.extend ClassMethods
|
||||
base.send :include, Redmine::NestedSet::Traversing
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def target_lft
|
||||
siblings_rgt = self.class.where(:parent_id => parent_id).where("name < ?", name).maximum(:rgt)
|
||||
if siblings_rgt
|
||||
siblings_rgt + 1
|
||||
elsif parent_id
|
||||
parent_lft = self.class.where(:id => parent_id).pluck(:lft).first
|
||||
raise "Project id=#{id} with parent_id=#{parent_id}: parent missing or without 'lft' value" unless parent_lft
|
||||
parent_lft + 1
|
||||
else
|
||||
1
|
||||
end
|
||||
end
|
||||
|
||||
def add_to_nested_set(lock=true)
|
||||
lock_nested_set if lock
|
||||
self.lft = target_lft
|
||||
self.rgt = lft + 1
|
||||
self.class.where("lft >= ? OR rgt >= ?", lft, lft).update_all([
|
||||
"lft = CASE WHEN lft >= :lft THEN lft + 2 ELSE lft END, " +
|
||||
"rgt = CASE WHEN rgt >= :lft THEN rgt + 2 ELSE rgt END",
|
||||
{:lft => lft}
|
||||
])
|
||||
end
|
||||
|
||||
def move_in_nested_set
|
||||
lock_nested_set
|
||||
reload_nested_set_values
|
||||
a = lft
|
||||
b = rgt
|
||||
c = target_lft
|
||||
unless c == a
|
||||
if c > a
|
||||
# Moving to the right
|
||||
d = c - (b - a + 1)
|
||||
scope = self.class.where(["lft BETWEEN :a AND :c - 1 OR rgt BETWEEN :a AND :c - 1", {:a => a, :c => c}])
|
||||
scope.update_all([
|
||||
"lft = CASE WHEN lft BETWEEN :a AND :b THEN lft + (:d - :a) WHEN lft BETWEEN :b + 1 AND :c - 1 THEN lft - (:b - :a + 1) ELSE lft END, " +
|
||||
"rgt = CASE WHEN rgt BETWEEN :a AND :b THEN rgt + (:d - :a) WHEN rgt BETWEEN :b + 1 AND :c - 1 THEN rgt - (:b - :a + 1) ELSE rgt END",
|
||||
{:a => a, :b => b, :c => c, :d => d}
|
||||
])
|
||||
elsif c < a
|
||||
# Moving to the left
|
||||
scope = self.class.where("lft BETWEEN :c AND :b OR rgt BETWEEN :c AND :b", {:a => a, :b => b, :c => c})
|
||||
scope.update_all([
|
||||
"lft = CASE WHEN lft BETWEEN :a AND :b THEN lft - (:a - :c) WHEN lft BETWEEN :c AND :a - 1 THEN lft + (:b - :a + 1) ELSE lft END, " +
|
||||
"rgt = CASE WHEN rgt BETWEEN :a AND :b THEN rgt - (:a - :c) WHEN rgt BETWEEN :c AND :a - 1 THEN rgt + (:b - :a + 1) ELSE rgt END",
|
||||
{:a => a, :b => b, :c => c, :d => d}
|
||||
])
|
||||
end
|
||||
reload_nested_set_values
|
||||
end
|
||||
end
|
||||
|
||||
def destroy_children
|
||||
unless @without_nested_set_update
|
||||
lock_nested_set
|
||||
reload_nested_set_values
|
||||
end
|
||||
children.each {|c| c.send :destroy_without_nested_set_update}
|
||||
unless @without_nested_set_update
|
||||
self.class.where("lft > ? OR rgt > ?", lft, lft).update_all([
|
||||
"lft = CASE WHEN lft > :lft THEN lft - :shift ELSE lft END, " +
|
||||
"rgt = CASE WHEN rgt > :lft THEN rgt - :shift ELSE rgt END",
|
||||
{:lft => lft, :shift => rgt - lft + 1}
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
def destroy_without_nested_set_update
|
||||
@without_nested_set_update = true
|
||||
destroy
|
||||
end
|
||||
|
||||
def reload_nested_set_values
|
||||
self.lft, self.rgt = Project.where(:id => id).pluck(:lft, :rgt).first
|
||||
end
|
||||
|
||||
def save_nested_set_values
|
||||
self.class.where(:id => id).update_all(:lft => lft, :rgt => rgt)
|
||||
end
|
||||
|
||||
def move_possible?(project)
|
||||
new_record? || !is_or_is_ancestor_of?(project)
|
||||
end
|
||||
|
||||
def lock_nested_set
|
||||
lock = true
|
||||
if self.class.connection.adapter_name =~ /sqlserver/i
|
||||
lock = "WITH (ROWLOCK HOLDLOCK UPDLOCK)"
|
||||
end
|
||||
self.class.order(:id).lock(lock).ids
|
||||
end
|
||||
|
||||
def nested_set_scope
|
||||
self.class.order(:lft)
|
||||
end
|
||||
|
||||
def same_nested_set_scope?(project)
|
||||
true
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def rebuild_tree!
|
||||
transaction do
|
||||
reorder(:id).lock.ids
|
||||
update_all(:lft => nil, :rgt => nil)
|
||||
rebuild_nodes
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rebuild_nodes(parent_id = nil)
|
||||
nodes = Project.where(:parent_id => parent_id).where(:rgt => nil, :lft => nil).reorder(:name)
|
||||
|
||||
nodes.each do |node|
|
||||
node.send :add_to_nested_set, false
|
||||
node.send :save_nested_set_values
|
||||
rebuild_nodes node.id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
124
lib/redmine/nested_set/traversing.rb
Normal file
124
lib/redmine/nested_set/traversing.rb
Normal file
|
@ -0,0 +1,124 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module NestedSet
|
||||
module Traversing
|
||||
def self.included(base)
|
||||
base.class_eval do
|
||||
scope :roots, lambda {where :parent_id => nil}
|
||||
scope :leaves, lambda {where "#{table_name}.rgt - #{table_name}.lft = ?", 1}
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the element has no parent
|
||||
def root?
|
||||
parent_id.nil?
|
||||
end
|
||||
|
||||
# Returns true if the element has a parent
|
||||
def child?
|
||||
!root?
|
||||
end
|
||||
|
||||
# Returns true if the element has no children
|
||||
def leaf?
|
||||
new_record? || (rgt - lft == 1)
|
||||
end
|
||||
|
||||
# Returns the root element (ancestor with no parent)
|
||||
def root
|
||||
self_and_ancestors.first
|
||||
end
|
||||
|
||||
# Returns the children
|
||||
def children
|
||||
if id.nil?
|
||||
nested_set_scope.none
|
||||
else
|
||||
self.class.order(:lft).where(:parent_id => id)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the descendants that have no children
|
||||
def leaves
|
||||
descendants.where("#{self.class.table_name}.rgt - #{self.class.table_name}.lft = ?", 1)
|
||||
end
|
||||
|
||||
# Returns the siblings
|
||||
def siblings
|
||||
nested_set_scope.where(:parent_id => parent_id).where("#{self.class.table_name}.id <> ?", id)
|
||||
end
|
||||
|
||||
# Returns the ancestors
|
||||
def ancestors
|
||||
if root?
|
||||
nested_set_scope.none
|
||||
else
|
||||
nested_set_scope.where("#{self.class.table_name}.lft < ? AND #{self.class.table_name}.rgt > ?", lft, rgt)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the element and its ancestors
|
||||
def self_and_ancestors
|
||||
nested_set_scope.where("#{self.class.table_name}.lft <= ? AND #{self.class.table_name}.rgt >= ?", lft, rgt)
|
||||
end
|
||||
|
||||
# Returns true if the element is an ancestor of other
|
||||
def is_ancestor_of?(other)
|
||||
same_nested_set_scope?(other) && other.lft > lft && other.rgt < rgt
|
||||
end
|
||||
|
||||
# Returns true if the element equals other or is an ancestor of other
|
||||
def is_or_is_ancestor_of?(other)
|
||||
other == self || is_ancestor_of?(other)
|
||||
end
|
||||
|
||||
# Returns the descendants
|
||||
def descendants
|
||||
if leaf?
|
||||
nested_set_scope.none
|
||||
else
|
||||
nested_set_scope.where("#{self.class.table_name}.lft > ? AND #{self.class.table_name}.rgt < ?", lft, rgt)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the element and its descendants
|
||||
def self_and_descendants
|
||||
nested_set_scope.where("#{self.class.table_name}.lft >= ? AND #{self.class.table_name}.rgt <= ?", lft, rgt)
|
||||
end
|
||||
|
||||
# Returns true if the element is a descendant of other
|
||||
def is_descendant_of?(other)
|
||||
same_nested_set_scope?(other) && other.lft < lft && other.rgt > rgt
|
||||
end
|
||||
|
||||
# Returns true if the element equals other or is a descendant of other
|
||||
def is_or_is_descendant_of?(other)
|
||||
other == self || is_descendant_of?(other)
|
||||
end
|
||||
|
||||
# Returns the ancestors, the element and its descendants
|
||||
def hierarchy
|
||||
nested_set_scope.where(
|
||||
"#{self.class.table_name}.lft >= :lft AND #{self.class.table_name}.rgt <= :rgt" +
|
||||
" OR #{self.class.table_name}.lft < :lft AND #{self.class.table_name}.rgt > :rgt",
|
||||
{:lft => lft, :rgt => rgt})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
27
lib/redmine/notifiable.rb
Normal file
27
lib/redmine/notifiable.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
module Redmine
|
||||
class Notifiable < Struct.new(:name, :parent)
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
# TODO: Plugin API for adding a new notification?
|
||||
def self.all
|
||||
notifications = []
|
||||
notifications << Notifiable.new('issue_added')
|
||||
notifications << Notifiable.new('issue_updated')
|
||||
notifications << Notifiable.new('issue_note_added', 'issue_updated')
|
||||
notifications << Notifiable.new('issue_status_updated', 'issue_updated')
|
||||
notifications << Notifiable.new('issue_assigned_to_updated', 'issue_updated')
|
||||
notifications << Notifiable.new('issue_priority_updated', 'issue_updated')
|
||||
notifications << Notifiable.new('news_added')
|
||||
notifications << Notifiable.new('news_comment_added')
|
||||
notifications << Notifiable.new('document_added')
|
||||
notifications << Notifiable.new('file_added')
|
||||
notifications << Notifiable.new('message_posted')
|
||||
notifications << Notifiable.new('wiki_content_added')
|
||||
notifications << Notifiable.new('wiki_content_updated')
|
||||
notifications
|
||||
end
|
||||
end
|
||||
end
|
249
lib/redmine/pagination.rb
Normal file
249
lib/redmine/pagination.rb
Normal file
|
@ -0,0 +1,249 @@
|
|||
# encoding: utf-8
|
||||
#
|
||||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Pagination
|
||||
class Paginator
|
||||
attr_reader :item_count, :per_page, :page, :page_param
|
||||
|
||||
def initialize(*args)
|
||||
if args.first.is_a?(ActionController::Base)
|
||||
args.shift
|
||||
ActiveSupport::Deprecation.warn "Paginator no longer takes a controller instance as the first argument. Remove it from #new arguments."
|
||||
end
|
||||
item_count, per_page, page, page_param = *args
|
||||
|
||||
@item_count = item_count
|
||||
@per_page = per_page
|
||||
page = (page || 1).to_i
|
||||
if page < 1
|
||||
page = 1
|
||||
end
|
||||
@page = page
|
||||
@page_param = page_param || :page
|
||||
end
|
||||
|
||||
def offset
|
||||
(page - 1) * per_page
|
||||
end
|
||||
|
||||
def first_page
|
||||
if item_count > 0
|
||||
1
|
||||
end
|
||||
end
|
||||
|
||||
def previous_page
|
||||
if page > 1
|
||||
page - 1
|
||||
end
|
||||
end
|
||||
|
||||
def next_page
|
||||
if last_item < item_count
|
||||
page + 1
|
||||
end
|
||||
end
|
||||
|
||||
def last_page
|
||||
if item_count > 0
|
||||
(item_count - 1) / per_page + 1
|
||||
end
|
||||
end
|
||||
|
||||
def multiple_pages?
|
||||
per_page < item_count
|
||||
end
|
||||
|
||||
def first_item
|
||||
item_count == 0 ? 0 : (offset + 1)
|
||||
end
|
||||
|
||||
def last_item
|
||||
l = first_item + per_page - 1
|
||||
l > item_count ? item_count : l
|
||||
end
|
||||
|
||||
def linked_pages
|
||||
pages = []
|
||||
if item_count > 0
|
||||
pages += [first_page, page, last_page]
|
||||
pages += ((page-2)..(page+2)).to_a.select {|p| p > first_page && p < last_page}
|
||||
end
|
||||
pages = pages.compact.uniq.sort
|
||||
if pages.size > 1
|
||||
pages
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def items_per_page
|
||||
ActiveSupport::Deprecation.warn "Paginator#items_per_page will be removed. Use #per_page instead."
|
||||
per_page
|
||||
end
|
||||
|
||||
def current
|
||||
ActiveSupport::Deprecation.warn "Paginator#current will be removed. Use .offset instead of .current.offset."
|
||||
self
|
||||
end
|
||||
end
|
||||
|
||||
# Paginates the given scope or model. Returns a Paginator instance and
|
||||
# the collection of objects for the current page.
|
||||
#
|
||||
# Options:
|
||||
# :parameter name of the page parameter
|
||||
#
|
||||
# Examples:
|
||||
# @user_pages, @users = paginate User.where(:status => 1)
|
||||
#
|
||||
def paginate(scope, options={})
|
||||
options = options.dup
|
||||
|
||||
paginator = paginator(scope.count, options)
|
||||
collection = scope.limit(paginator.per_page).offset(paginator.offset).to_a
|
||||
|
||||
return paginator, collection
|
||||
end
|
||||
|
||||
def paginator(item_count, options={})
|
||||
options.assert_valid_keys :parameter, :per_page
|
||||
|
||||
page_param = options[:parameter] || :page
|
||||
page = (params[page_param] || 1).to_i
|
||||
per_page = options[:per_page] || per_page_option
|
||||
Paginator.new(item_count, per_page, page, page_param)
|
||||
end
|
||||
|
||||
module Helper
|
||||
include Redmine::I18n
|
||||
|
||||
# Renders the pagination links for the given paginator.
|
||||
#
|
||||
# Options:
|
||||
# :per_page_links if set to false, the "Per page" links are not rendered
|
||||
#
|
||||
def pagination_links_full(*args)
|
||||
pagination_links_each(*args) do |text, parameters, options|
|
||||
if block_given?
|
||||
yield text, parameters, options
|
||||
else
|
||||
link_to text, {:params => request.query_parameters.merge(parameters)}, options
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Yields the given block with the text and parameters
|
||||
# for each pagination link and returns a string that represents the links
|
||||
def pagination_links_each(paginator, count=nil, options={}, &block)
|
||||
options.assert_valid_keys :per_page_links
|
||||
|
||||
per_page_links = options.delete(:per_page_links)
|
||||
per_page_links = false if count.nil?
|
||||
page_param = paginator.page_param
|
||||
|
||||
html = '<ul class="pages">'
|
||||
|
||||
if paginator.multiple_pages?
|
||||
# \xc2\xab(utf-8) = «
|
||||
text = "\xc2\xab " + l(:label_previous)
|
||||
if paginator.previous_page
|
||||
html << content_tag('li',
|
||||
yield(text, {page_param => paginator.previous_page},
|
||||
:accesskey => accesskey(:previous)),
|
||||
:class => 'previous page')
|
||||
else
|
||||
html << content_tag('li', content_tag('span', text), :class => 'previous')
|
||||
end
|
||||
end
|
||||
|
||||
previous = nil
|
||||
paginator.linked_pages.each do |page|
|
||||
if previous && previous != page - 1
|
||||
html << content_tag('li', content_tag('span', '…'.html_safe), :class => 'spacer')
|
||||
end
|
||||
if page == paginator.page
|
||||
html << content_tag('li', content_tag('span', page.to_s), :class => 'current')
|
||||
else
|
||||
html << content_tag('li',
|
||||
yield(page.to_s, {page_param => page}),
|
||||
:class => 'page')
|
||||
end
|
||||
previous = page
|
||||
end
|
||||
|
||||
if paginator.multiple_pages?
|
||||
# \xc2\xbb(utf-8) = »
|
||||
text = l(:label_next) + " \xc2\xbb"
|
||||
if paginator.next_page
|
||||
html << content_tag('li',
|
||||
yield(text, {page_param => paginator.next_page},
|
||||
:accesskey => accesskey(:next)),
|
||||
:class => 'next page')
|
||||
else
|
||||
html << content_tag('li', content_tag('span', text), :class => 'next')
|
||||
end
|
||||
end
|
||||
html << '</ul>'
|
||||
|
||||
info = ''.html_safe
|
||||
info << content_tag('span', "(#{paginator.first_item}-#{paginator.last_item}/#{paginator.item_count})", :class => 'items') + ' '
|
||||
if per_page_links != false && links = per_page_links(paginator, &block)
|
||||
info << content_tag('span', links.to_s, :class => 'per-page')
|
||||
end
|
||||
html << content_tag('span', info)
|
||||
|
||||
html.html_safe
|
||||
end
|
||||
|
||||
# Renders the "Per page" links.
|
||||
def per_page_links(paginator, &block)
|
||||
values = per_page_options(paginator.per_page, paginator.item_count)
|
||||
if values.any?
|
||||
links = values.collect do |n|
|
||||
if n == paginator.per_page
|
||||
content_tag('span', n.to_s, :class => 'selected')
|
||||
else
|
||||
yield(n, :per_page => n, paginator.page_param => nil)
|
||||
end
|
||||
end
|
||||
l(:label_display_per_page, links.join(', ')).html_safe
|
||||
end
|
||||
end
|
||||
|
||||
def per_page_options(selected=nil, item_count=nil)
|
||||
options = Setting.per_page_options_array
|
||||
if item_count && options.any?
|
||||
if item_count > options.first
|
||||
max = options.detect {|value| value >= item_count} || item_count
|
||||
else
|
||||
max = item_count
|
||||
end
|
||||
options = options.select {|value| value <= max || value == selected}
|
||||
end
|
||||
if options.empty? || (options.size == 1 && options.first == selected)
|
||||
[]
|
||||
else
|
||||
options
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
27
lib/redmine/platform.rb
Normal file
27
lib/redmine/platform.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Platform
|
||||
class << self
|
||||
def mswin?
|
||||
(RUBY_PLATFORM =~ /(:?mswin|mingw)/) ||
|
||||
(RUBY_PLATFORM == 'java' && (ENV['OS'] || ENV['os']) =~ /windows/i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
504
lib/redmine/plugin.rb
Normal file
504
lib/redmine/plugin.rb
Normal file
|
@ -0,0 +1,504 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine #:nodoc:
|
||||
|
||||
class PluginNotFound < StandardError; end
|
||||
class PluginRequirementError < StandardError; end
|
||||
|
||||
# Base class for Redmine plugins.
|
||||
# Plugins are registered using the <tt>register</tt> class method that acts as the public constructor.
|
||||
#
|
||||
# Redmine::Plugin.register :example do
|
||||
# name 'Example plugin'
|
||||
# author 'John Smith'
|
||||
# description 'This is an example plugin for Redmine'
|
||||
# version '0.0.1'
|
||||
# settings :default => {'foo'=>'bar'}, :partial => 'settings/settings'
|
||||
# end
|
||||
#
|
||||
# === Plugin attributes
|
||||
#
|
||||
# +settings+ is an optional attribute that let the plugin be configurable.
|
||||
# It must be a hash with the following keys:
|
||||
# * <tt>:default</tt>: default value for the plugin settings
|
||||
# * <tt>:partial</tt>: path of the configuration partial view, relative to the plugin <tt>app/views</tt> directory
|
||||
# Example:
|
||||
# settings :default => {'foo'=>'bar'}, :partial => 'settings/settings'
|
||||
# In this example, the settings partial will be found here in the plugin directory: <tt>app/views/settings/_settings.rhtml</tt>.
|
||||
#
|
||||
# When rendered, the plugin settings value is available as the local variable +settings+
|
||||
class Plugin
|
||||
cattr_accessor :directory
|
||||
self.directory = File.join(Rails.root, 'plugins')
|
||||
|
||||
cattr_accessor :public_directory
|
||||
self.public_directory = File.join(Rails.root, 'public', 'plugin_assets')
|
||||
|
||||
@registered_plugins = {}
|
||||
@used_partials = {}
|
||||
|
||||
class << self
|
||||
attr_reader :registered_plugins
|
||||
private :new
|
||||
|
||||
def def_field(*names)
|
||||
class_eval do
|
||||
names.each do |name|
|
||||
define_method(name) do |*args|
|
||||
args.empty? ? instance_variable_get("@#{name}") : instance_variable_set("@#{name}", *args)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
def_field :name, :description, :url, :author, :author_url, :version, :settings, :directory
|
||||
attr_reader :id
|
||||
|
||||
# Plugin constructor
|
||||
def self.register(id, &block)
|
||||
p = new(id)
|
||||
p.instance_eval(&block)
|
||||
|
||||
# Set a default name if it was not provided during registration
|
||||
p.name(id.to_s.humanize) if p.name.nil?
|
||||
# Set a default directory if it was not provided during registration
|
||||
p.directory(File.join(self.directory, id.to_s)) if p.directory.nil?
|
||||
|
||||
# Adds plugin locales if any
|
||||
# YAML translation files should be found under <plugin>/config/locales/
|
||||
Rails.application.config.i18n.load_path += Dir.glob(File.join(p.directory, 'config', 'locales', '*.yml'))
|
||||
|
||||
# Prepends the app/views directory of the plugin to the view path
|
||||
view_path = File.join(p.directory, 'app', 'views')
|
||||
if File.directory?(view_path)
|
||||
ActionController::Base.prepend_view_path(view_path)
|
||||
ActionMailer::Base.prepend_view_path(view_path)
|
||||
end
|
||||
|
||||
# Adds the app/{controllers,helpers,models} directories of the plugin to the autoload path
|
||||
Dir.glob File.expand_path(File.join(p.directory, 'app', '{controllers,helpers,models}')) do |dir|
|
||||
ActiveSupport::Dependencies.autoload_paths += [dir]
|
||||
end
|
||||
|
||||
# Defines plugin setting if present
|
||||
if p.settings
|
||||
Setting.define_plugin_setting p
|
||||
end
|
||||
|
||||
# Warn for potential settings[:partial] collisions
|
||||
if p.configurable?
|
||||
partial = p.settings[:partial]
|
||||
if @used_partials[partial]
|
||||
Rails.logger.warn "WARNING: settings partial '#{partial}' is declared in '#{p.id}' plugin but it is already used by plugin '#{@used_partials[partial]}'. Only one settings view will be used. You may want to contact those plugins authors to fix this."
|
||||
end
|
||||
@used_partials[partial] = p.id
|
||||
end
|
||||
|
||||
registered_plugins[id] = p
|
||||
end
|
||||
|
||||
# Returns an array of all registered plugins
|
||||
def self.all
|
||||
registered_plugins.values.sort
|
||||
end
|
||||
|
||||
# Finds a plugin by its id
|
||||
# Returns a PluginNotFound exception if the plugin doesn't exist
|
||||
def self.find(id)
|
||||
registered_plugins[id.to_sym] || raise(PluginNotFound)
|
||||
end
|
||||
|
||||
# Clears the registered plugins hash
|
||||
# It doesn't unload installed plugins
|
||||
def self.clear
|
||||
@registered_plugins = {}
|
||||
end
|
||||
|
||||
# Removes a plugin from the registered plugins
|
||||
# It doesn't unload the plugin
|
||||
def self.unregister(id)
|
||||
@registered_plugins.delete(id)
|
||||
end
|
||||
|
||||
# Checks if a plugin is installed
|
||||
#
|
||||
# @param [String] id name of the plugin
|
||||
def self.installed?(id)
|
||||
registered_plugins[id.to_sym].present?
|
||||
end
|
||||
|
||||
def self.load
|
||||
Dir.glob(File.join(self.directory, '*')).sort.each do |directory|
|
||||
if File.directory?(directory)
|
||||
lib = File.join(directory, "lib")
|
||||
if File.directory?(lib)
|
||||
$:.unshift lib
|
||||
ActiveSupport::Dependencies.autoload_paths += [lib]
|
||||
end
|
||||
initializer = File.join(directory, "init.rb")
|
||||
if File.file?(initializer)
|
||||
require initializer
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(id)
|
||||
@id = id.to_sym
|
||||
end
|
||||
|
||||
def public_directory
|
||||
File.join(self.class.public_directory, id.to_s)
|
||||
end
|
||||
|
||||
def to_param
|
||||
id
|
||||
end
|
||||
|
||||
def assets_directory
|
||||
File.join(directory, 'assets')
|
||||
end
|
||||
|
||||
def <=>(plugin)
|
||||
self.id.to_s <=> plugin.id.to_s
|
||||
end
|
||||
|
||||
# Sets a requirement on Redmine version
|
||||
# Raises a PluginRequirementError exception if the requirement is not met
|
||||
#
|
||||
# Examples
|
||||
# # Requires Redmine 0.7.3 or higher
|
||||
# requires_redmine :version_or_higher => '0.7.3'
|
||||
# requires_redmine '0.7.3'
|
||||
#
|
||||
# # Requires Redmine 0.7.x or higher
|
||||
# requires_redmine '0.7'
|
||||
#
|
||||
# # Requires a specific Redmine version
|
||||
# requires_redmine :version => '0.7.3' # 0.7.3 only
|
||||
# requires_redmine :version => '0.7' # 0.7.x
|
||||
# requires_redmine :version => ['0.7.3', '0.8.0'] # 0.7.3 or 0.8.0
|
||||
#
|
||||
# # Requires a Redmine version within a range
|
||||
# requires_redmine :version => '0.7.3'..'0.9.1' # >= 0.7.3 and <= 0.9.1
|
||||
# requires_redmine :version => '0.7'..'0.9' # >= 0.7.x and <= 0.9.x
|
||||
def requires_redmine(arg)
|
||||
arg = { :version_or_higher => arg } unless arg.is_a?(Hash)
|
||||
arg.assert_valid_keys(:version, :version_or_higher)
|
||||
|
||||
current = Redmine::VERSION.to_a
|
||||
arg.each do |k, req|
|
||||
case k
|
||||
when :version_or_higher
|
||||
raise ArgumentError.new(":version_or_higher accepts a version string only") unless req.is_a?(String)
|
||||
unless compare_versions(req, current) <= 0
|
||||
raise PluginRequirementError.new("#{id} plugin requires Redmine #{req} or higher but current is #{current.join('.')}")
|
||||
end
|
||||
when :version
|
||||
req = [req] if req.is_a?(String)
|
||||
if req.is_a?(Array)
|
||||
unless req.detect {|ver| compare_versions(ver, current) == 0}
|
||||
raise PluginRequirementError.new("#{id} plugin requires one the following Redmine versions: #{req.join(', ')} but current is #{current.join('.')}")
|
||||
end
|
||||
elsif req.is_a?(Range)
|
||||
unless compare_versions(req.first, current) <= 0 && compare_versions(req.last, current) >= 0
|
||||
raise PluginRequirementError.new("#{id} plugin requires a Redmine version between #{req.first} and #{req.last} but current is #{current.join('.')}")
|
||||
end
|
||||
else
|
||||
raise ArgumentError.new(":version option accepts a version string, an array or a range of versions")
|
||||
end
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def compare_versions(requirement, current)
|
||||
requirement = requirement.split('.').collect(&:to_i)
|
||||
requirement <=> current.slice(0, requirement.size)
|
||||
end
|
||||
private :compare_versions
|
||||
|
||||
# Sets a requirement on a Redmine plugin version
|
||||
# Raises a PluginRequirementError exception if the requirement is not met
|
||||
#
|
||||
# Examples
|
||||
# # Requires a plugin named :foo version 0.7.3 or higher
|
||||
# requires_redmine_plugin :foo, :version_or_higher => '0.7.3'
|
||||
# requires_redmine_plugin :foo, '0.7.3'
|
||||
#
|
||||
# # Requires a specific version of a Redmine plugin
|
||||
# requires_redmine_plugin :foo, :version => '0.7.3' # 0.7.3 only
|
||||
# requires_redmine_plugin :foo, :version => ['0.7.3', '0.8.0'] # 0.7.3 or 0.8.0
|
||||
def requires_redmine_plugin(plugin_name, arg)
|
||||
arg = { :version_or_higher => arg } unless arg.is_a?(Hash)
|
||||
arg.assert_valid_keys(:version, :version_or_higher)
|
||||
|
||||
plugin = Plugin.find(plugin_name)
|
||||
current = plugin.version.split('.').collect(&:to_i)
|
||||
|
||||
arg.each do |k, v|
|
||||
v = [] << v unless v.is_a?(Array)
|
||||
versions = v.collect {|s| s.split('.').collect(&:to_i)}
|
||||
case k
|
||||
when :version_or_higher
|
||||
raise ArgumentError.new("wrong number of versions (#{versions.size} for 1)") unless versions.size == 1
|
||||
unless (current <=> versions.first) >= 0
|
||||
raise PluginRequirementError.new("#{id} plugin requires the #{plugin_name} plugin #{v} or higher but current is #{current.join('.')}")
|
||||
end
|
||||
when :version
|
||||
unless versions.include?(current.slice(0,3))
|
||||
raise PluginRequirementError.new("#{id} plugin requires one the following versions of #{plugin_name}: #{v.join(', ')} but current is #{current.join('.')}")
|
||||
end
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
# Adds an item to the given +menu+.
|
||||
# The +id+ parameter (equals to the project id) is automatically added to the url.
|
||||
# menu :project_menu, :plugin_example, { :controller => 'example', :action => 'say_hello' }, :caption => 'Sample'
|
||||
#
|
||||
# +name+ parameter can be: :top_menu, :account_menu, :application_menu or :project_menu
|
||||
#
|
||||
def menu(menu, item, url, options={})
|
||||
Redmine::MenuManager.map(menu).push(item, url, options)
|
||||
end
|
||||
alias :add_menu_item :menu
|
||||
|
||||
# Removes +item+ from the given +menu+.
|
||||
def delete_menu_item(menu, item)
|
||||
Redmine::MenuManager.map(menu).delete(item)
|
||||
end
|
||||
|
||||
# Defines a permission called +name+ for the given +actions+.
|
||||
#
|
||||
# The +actions+ argument is a hash with controllers as keys and actions as values (a single value or an array):
|
||||
# permission :destroy_contacts, { :contacts => :destroy }
|
||||
# permission :view_contacts, { :contacts => [:index, :show] }
|
||||
#
|
||||
# The +options+ argument is a hash that accept the following keys:
|
||||
# * :public => the permission is public if set to true (implicitly given to any user)
|
||||
# * :require => can be set to one of the following values to restrict users the permission can be given to: :loggedin, :member
|
||||
# * :read => set it to true so that the permission is still granted on closed projects
|
||||
#
|
||||
# Examples
|
||||
# # A permission that is implicitly given to any user
|
||||
# # This permission won't appear on the Roles & Permissions setup screen
|
||||
# permission :say_hello, { :example => :say_hello }, :public => true, :read => true
|
||||
#
|
||||
# # A permission that can be given to any user
|
||||
# permission :say_hello, { :example => :say_hello }
|
||||
#
|
||||
# # A permission that can be given to registered users only
|
||||
# permission :say_hello, { :example => :say_hello }, :require => :loggedin
|
||||
#
|
||||
# # A permission that can be given to project members only
|
||||
# permission :say_hello, { :example => :say_hello }, :require => :member
|
||||
def permission(name, actions, options = {})
|
||||
if @project_module
|
||||
Redmine::AccessControl.map {|map| map.project_module(@project_module) {|map|map.permission(name, actions, options)}}
|
||||
else
|
||||
Redmine::AccessControl.map {|map| map.permission(name, actions, options)}
|
||||
end
|
||||
end
|
||||
|
||||
# Defines a project module, that can be enabled/disabled for each project.
|
||||
# Permissions defined inside +block+ will be bind to the module.
|
||||
#
|
||||
# project_module :things do
|
||||
# permission :view_contacts, { :contacts => [:list, :show] }, :public => true
|
||||
# permission :destroy_contacts, { :contacts => :destroy }
|
||||
# end
|
||||
def project_module(name, &block)
|
||||
@project_module = name
|
||||
self.instance_eval(&block)
|
||||
@project_module = nil
|
||||
end
|
||||
|
||||
# Registers an activity provider.
|
||||
#
|
||||
# Options:
|
||||
# * <tt>:class_name</tt> - one or more model(s) that provide these events (inferred from event_type by default)
|
||||
# * <tt>:default</tt> - setting this option to false will make the events not displayed by default
|
||||
#
|
||||
# A model can provide several activity event types.
|
||||
#
|
||||
# Examples:
|
||||
# register :news
|
||||
# register :scrums, :class_name => 'Meeting'
|
||||
# register :issues, :class_name => ['Issue', 'Journal']
|
||||
#
|
||||
# Retrieving events:
|
||||
# Associated model(s) must implement the find_events class method.
|
||||
# ActiveRecord models can use acts_as_activity_provider as a way to implement this class method.
|
||||
#
|
||||
# The following call should return all the scrum events visible by current user that occurred in the 5 last days:
|
||||
# Meeting.find_events('scrums', User.current, 5.days.ago, Date.today)
|
||||
# Meeting.find_events('scrums', User.current, 5.days.ago, Date.today, :project => foo) # events for project foo only
|
||||
#
|
||||
# Note that :view_scrums permission is required to view these events in the activity view.
|
||||
def activity_provider(*args)
|
||||
Redmine::Activity.register(*args)
|
||||
end
|
||||
|
||||
# Registers a wiki formatter.
|
||||
#
|
||||
# Parameters:
|
||||
# * +name+ - formatter name
|
||||
# * +formatter+ - formatter class, which should have an instance method +to_html+
|
||||
# * +helper+ - helper module, which will be included by wiki pages (optional)
|
||||
# * +html_parser+ class reponsible for converting HTML to wiki text (optional)
|
||||
# * +options+ - a Hash of options (optional)
|
||||
# * :label - label for the formatter displayed in application settings
|
||||
#
|
||||
# Examples:
|
||||
# wiki_format_provider(:custom_formatter, CustomFormatter, :label => "My custom formatter")
|
||||
#
|
||||
def wiki_format_provider(name, *args)
|
||||
Redmine::WikiFormatting.register(name, *args)
|
||||
end
|
||||
|
||||
# Returns +true+ if the plugin can be configured.
|
||||
def configurable?
|
||||
settings && settings.is_a?(Hash) && !settings[:partial].blank?
|
||||
end
|
||||
|
||||
def mirror_assets
|
||||
source = assets_directory
|
||||
destination = public_directory
|
||||
return unless File.directory?(source)
|
||||
|
||||
source_files = Dir[source + "/**/*"]
|
||||
source_dirs = source_files.select { |d| File.directory?(d) }
|
||||
source_files -= source_dirs
|
||||
|
||||
unless source_files.empty?
|
||||
base_target_dir = File.join(destination, File.dirname(source_files.first).gsub(source, ''))
|
||||
begin
|
||||
FileUtils.mkdir_p(base_target_dir)
|
||||
rescue Exception => e
|
||||
raise "Could not create directory #{base_target_dir}: " + e.message
|
||||
end
|
||||
end
|
||||
|
||||
source_dirs.each do |dir|
|
||||
# strip down these paths so we have simple, relative paths we can
|
||||
# add to the destination
|
||||
target_dir = File.join(destination, dir.gsub(source, ''))
|
||||
begin
|
||||
FileUtils.mkdir_p(target_dir)
|
||||
rescue Exception => e
|
||||
raise "Could not create directory #{target_dir}: " + e.message
|
||||
end
|
||||
end
|
||||
|
||||
source_files.each do |file|
|
||||
begin
|
||||
target = File.join(destination, file.gsub(source, ''))
|
||||
unless File.exist?(target) && FileUtils.identical?(file, target)
|
||||
FileUtils.cp(file, target)
|
||||
end
|
||||
rescue Exception => e
|
||||
raise "Could not copy #{file} to #{target}: " + e.message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Mirrors assets from one or all plugins to public/plugin_assets
|
||||
def self.mirror_assets(name=nil)
|
||||
if name.present?
|
||||
find(name).mirror_assets
|
||||
else
|
||||
all.each do |plugin|
|
||||
plugin.mirror_assets
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# The directory containing this plugin's migrations (<tt>plugin/db/migrate</tt>)
|
||||
def migration_directory
|
||||
File.join(directory, 'db', 'migrate')
|
||||
end
|
||||
|
||||
# Returns the version number of the latest migration for this plugin. Returns
|
||||
# nil if this plugin has no migrations.
|
||||
def latest_migration
|
||||
migrations.last
|
||||
end
|
||||
|
||||
# Returns the version numbers of all migrations for this plugin.
|
||||
def migrations
|
||||
migrations = Dir[migration_directory+"/*.rb"]
|
||||
migrations.map { |p| File.basename(p).match(/0*(\d+)\_/)[1].to_i }.sort
|
||||
end
|
||||
|
||||
# Migrate this plugin to the given version
|
||||
def migrate(version = nil)
|
||||
puts "Migrating #{id} (#{name})..."
|
||||
Redmine::Plugin::Migrator.migrate_plugin(self, version)
|
||||
end
|
||||
|
||||
# Migrates all plugins or a single plugin to a given version
|
||||
# Exemples:
|
||||
# Plugin.migrate
|
||||
# Plugin.migrate('sample_plugin')
|
||||
# Plugin.migrate('sample_plugin', 1)
|
||||
#
|
||||
def self.migrate(name=nil, version=nil)
|
||||
if name.present?
|
||||
find(name).migrate(version)
|
||||
else
|
||||
all.each do |plugin|
|
||||
plugin.migrate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Migrator < ActiveRecord::Migrator
|
||||
# We need to be able to set the 'current' plugin being migrated.
|
||||
cattr_accessor :current_plugin
|
||||
|
||||
class << self
|
||||
# Runs the migrations from a plugin, up (or down) to the version given
|
||||
def migrate_plugin(plugin, version)
|
||||
self.current_plugin = plugin
|
||||
return if current_version(plugin) == version
|
||||
migrate(plugin.migration_directory, version)
|
||||
end
|
||||
|
||||
def current_version(plugin=current_plugin)
|
||||
# Delete migrations that don't match .. to_i will work because the number comes first
|
||||
::ActiveRecord::Base.connection.select_values(
|
||||
"SELECT version FROM #{schema_migrations_table_name}"
|
||||
).delete_if{ |v| v.match(/-#{plugin.id}$/) == nil }.map(&:to_i).max || 0
|
||||
end
|
||||
end
|
||||
|
||||
def migrated
|
||||
sm_table = self.class.schema_migrations_table_name
|
||||
::ActiveRecord::Base.connection.select_values(
|
||||
"SELECT version FROM #{sm_table}"
|
||||
).delete_if{ |v| v.match(/-#{current_plugin.id}$/) == nil }.map(&:to_i).sort
|
||||
end
|
||||
|
||||
def record_version_state_after_migrating(version)
|
||||
super(version.to_s + "-" + current_plugin.id.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
74
lib/redmine/pop3.rb
Normal file
74
lib/redmine/pop3.rb
Normal file
|
@ -0,0 +1,74 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'net/pop'
|
||||
|
||||
module Redmine
|
||||
module POP3
|
||||
class << self
|
||||
def check(pop_options={}, options={})
|
||||
if pop_options[:ssl]
|
||||
ssl = true
|
||||
if pop_options[:ssl] == 'force'
|
||||
Net::POP3.enable_ssl(OpenSSL::SSL::VERIFY_NONE)
|
||||
else
|
||||
Net::POP3.enable_ssl(OpenSSL::SSL::VERIFY_PEER)
|
||||
end
|
||||
else
|
||||
ssl = false
|
||||
end
|
||||
|
||||
host = pop_options[:host] || '127.0.0.1'
|
||||
port = pop_options[:port]
|
||||
port ||= ssl ? '995' : '110'
|
||||
apop = (pop_options[:apop].to_s == '1')
|
||||
delete_unprocessed = (pop_options[:delete_unprocessed].to_s == '1')
|
||||
|
||||
pop = Net::POP3.APOP(apop).new(host,port)
|
||||
logger.debug "Connecting to #{host}..." if logger && logger.debug?
|
||||
pop.start(pop_options[:username], pop_options[:password]) do |pop_session|
|
||||
if pop_session.mails.empty?
|
||||
logger.debug "No email to process" if logger && logger.debug?
|
||||
else
|
||||
logger.debug "#{pop_session.mails.size} email(s) to process..." if logger && logger.debug?
|
||||
pop_session.each_mail do |msg|
|
||||
message = msg.pop
|
||||
message_id = (message =~ /^Message-I[dD]: (.*)/ ? $1 : '').strip
|
||||
if MailHandler.safe_receive(message, options)
|
||||
msg.delete
|
||||
logger.debug "--> Message #{message_id} processed and deleted from the server" if logger && logger.debug?
|
||||
else
|
||||
if delete_unprocessed
|
||||
msg.delete
|
||||
logger.debug "--> Message #{message_id} NOT processed and deleted from the server" if logger && logger.debug?
|
||||
else
|
||||
logger.debug "--> Message #{message_id} NOT processed and left on the server" if logger && logger.debug?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def logger
|
||||
::Rails.logger
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
87
lib/redmine/safe_attributes.rb
Normal file
87
lib/redmine/safe_attributes.rb
Normal file
|
@ -0,0 +1,87 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module SafeAttributes
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
# Declares safe attributes
|
||||
# An optional Proc can be given for conditional inclusion
|
||||
#
|
||||
# Example:
|
||||
# safe_attributes 'title', 'pages'
|
||||
# safe_attributes 'isbn', :if => {|book, user| book.author == user}
|
||||
def safe_attributes(*args)
|
||||
@safe_attributes ||= []
|
||||
if args.empty?
|
||||
if superclass.include?(Redmine::SafeAttributes)
|
||||
@safe_attributes + superclass.safe_attributes
|
||||
else
|
||||
@safe_attributes
|
||||
end
|
||||
else
|
||||
options = args.last.is_a?(Hash) ? args.pop : {}
|
||||
@safe_attributes << [args, options]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an array that can be safely set by user or current user
|
||||
#
|
||||
# Example:
|
||||
# book.safe_attributes # => ['title', 'pages']
|
||||
# book.safe_attributes(book.author) # => ['title', 'pages', 'isbn']
|
||||
def safe_attribute_names(user=nil)
|
||||
return @safe_attribute_names if @safe_attribute_names && user.nil?
|
||||
names = []
|
||||
self.class.safe_attributes.collect do |attrs, options|
|
||||
if options[:if].nil? || options[:if].call(self, user || User.current)
|
||||
names += attrs.collect(&:to_s)
|
||||
end
|
||||
end
|
||||
names.uniq!
|
||||
@safe_attribute_names = names if user.nil?
|
||||
names
|
||||
end
|
||||
|
||||
# Returns true if attr can be set by user or the current user
|
||||
def safe_attribute?(attr, user=nil)
|
||||
safe_attribute_names(user).include?(attr.to_s)
|
||||
end
|
||||
|
||||
# Returns a hash with unsafe attributes removed
|
||||
# from the given attrs hash
|
||||
#
|
||||
# Example:
|
||||
# book.delete_unsafe_attributes({'title' => 'My book', 'foo' => 'bar'})
|
||||
# # => {'title' => 'My book'}
|
||||
def delete_unsafe_attributes(attrs, user=User.current)
|
||||
safe = safe_attribute_names(user)
|
||||
attrs.dup.delete_if {|k,v| !safe.include?(k.to_s)}
|
||||
end
|
||||
|
||||
# Sets attributes from attrs that are safe
|
||||
# attrs is a Hash with string keys
|
||||
def safe_attributes=(attrs, user=User.current)
|
||||
return unless attrs.is_a?(Hash)
|
||||
self.attributes = delete_unsafe_attributes(attrs, user)
|
||||
end
|
||||
end
|
||||
end
|
23
lib/redmine/scm/adapters.rb
Normal file
23
lib/redmine/scm/adapters.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
end
|
||||
end
|
||||
end
|
434
lib/redmine/scm/adapters/abstract_adapter.rb
Normal file
434
lib/redmine/scm/adapters/abstract_adapter.rb
Normal file
|
@ -0,0 +1,434 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'cgi'
|
||||
require 'redmine/scm/adapters'
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class AbstractAdapter #:nodoc:
|
||||
include Redmine::Utils::Shell
|
||||
|
||||
# raised if scm command exited with error, e.g. unknown revision.
|
||||
class ScmCommandAborted < ::Redmine::Scm::Adapters::CommandFailed; end
|
||||
|
||||
class << self
|
||||
|
||||
def client_command
|
||||
""
|
||||
end
|
||||
|
||||
def shell_quote(str)
|
||||
Redmine::Utils::Shell.shell_quote str
|
||||
end
|
||||
|
||||
def shell_quote_command
|
||||
Redmine::Utils::Shell.shell_quote_command client_command
|
||||
end
|
||||
|
||||
# Returns the version of the scm client
|
||||
# Eg: [1, 5, 0] or [] if unknown
|
||||
def client_version
|
||||
[]
|
||||
end
|
||||
|
||||
# Returns the version string of the scm client
|
||||
# Eg: '1.5.0' or 'Unknown version' if unknown
|
||||
def client_version_string
|
||||
v = client_version || 'Unknown version'
|
||||
v.is_a?(Array) ? v.join('.') : v.to_s
|
||||
end
|
||||
|
||||
# Returns true if the current client version is above
|
||||
# or equals the given one
|
||||
# If option is :unknown is set to true, it will return
|
||||
# true if the client version is unknown
|
||||
def client_version_above?(v, options={})
|
||||
((client_version <=> v) >= 0) || (client_version.empty? && options[:unknown])
|
||||
end
|
||||
|
||||
def client_available
|
||||
true
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def initialize(url, root_url=nil, login=nil, password=nil,
|
||||
path_encoding=nil)
|
||||
@url = url
|
||||
@login = login if login && !login.empty?
|
||||
@password = (password || "") if @login
|
||||
@root_url = root_url.blank? ? retrieve_root_url : root_url
|
||||
end
|
||||
|
||||
def adapter_name
|
||||
'Abstract'
|
||||
end
|
||||
|
||||
def supports_cat?
|
||||
true
|
||||
end
|
||||
|
||||
def supports_annotate?
|
||||
respond_to?('annotate')
|
||||
end
|
||||
|
||||
def root_url
|
||||
@root_url
|
||||
end
|
||||
|
||||
def url
|
||||
@url
|
||||
end
|
||||
|
||||
def path_encoding
|
||||
nil
|
||||
end
|
||||
|
||||
# get info about the svn repository
|
||||
def info
|
||||
return nil
|
||||
end
|
||||
|
||||
# Returns the entry identified by path and revision identifier
|
||||
# or nil if entry doesn't exist in the repository
|
||||
def entry(path=nil, identifier=nil)
|
||||
parts = path.to_s.split(%r{[\/\\]}).select {|n| !n.blank?}
|
||||
search_path = parts[0..-2].join('/')
|
||||
search_name = parts[-1]
|
||||
if search_path.blank? && search_name.blank?
|
||||
# Root entry
|
||||
Entry.new(:path => '', :kind => 'dir')
|
||||
else
|
||||
# Search for the entry in the parent directory
|
||||
es = entries(search_path, identifier)
|
||||
es ? es.detect {|e| e.name == search_name} : nil
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an Entries collection
|
||||
# or nil if the given path doesn't exist in the repository
|
||||
def entries(path=nil, identifier=nil, options={})
|
||||
return nil
|
||||
end
|
||||
|
||||
def branches
|
||||
return nil
|
||||
end
|
||||
|
||||
def tags
|
||||
return nil
|
||||
end
|
||||
|
||||
def default_branch
|
||||
return nil
|
||||
end
|
||||
|
||||
def properties(path, identifier=nil)
|
||||
return nil
|
||||
end
|
||||
|
||||
def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={})
|
||||
return nil
|
||||
end
|
||||
|
||||
def diff(path, identifier_from, identifier_to=nil)
|
||||
return nil
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
return nil
|
||||
end
|
||||
|
||||
def with_leading_slash(path)
|
||||
path ||= ''
|
||||
(path[0,1]!="/") ? "/#{path}" : path
|
||||
end
|
||||
|
||||
def with_trailling_slash(path)
|
||||
path ||= ''
|
||||
(path[-1,1] == "/") ? path : "#{path}/"
|
||||
end
|
||||
|
||||
def without_leading_slash(path)
|
||||
path ||= ''
|
||||
path.gsub(%r{^/+}, '')
|
||||
end
|
||||
|
||||
def without_trailling_slash(path)
|
||||
path ||= ''
|
||||
(path[-1,1] == "/") ? path[0..-2] : path
|
||||
end
|
||||
|
||||
private
|
||||
def retrieve_root_url
|
||||
info = self.info
|
||||
info ? info.root_url : nil
|
||||
end
|
||||
|
||||
def target(path, sq=true)
|
||||
path ||= ''
|
||||
base = path.match(/^\//) ? root_url : url
|
||||
str = "#{base}/#{path}".gsub(/[?<>\*]/, '')
|
||||
if sq
|
||||
str = shell_quote(str)
|
||||
end
|
||||
str
|
||||
end
|
||||
|
||||
def logger
|
||||
self.class.logger
|
||||
end
|
||||
|
||||
def shellout(cmd, options = {}, &block)
|
||||
self.class.shellout(cmd, options, &block)
|
||||
end
|
||||
|
||||
def self.logger
|
||||
Rails.logger
|
||||
end
|
||||
|
||||
# Path to the file where scm stderr output is logged
|
||||
# Returns nil if the log file is not writable
|
||||
def self.stderr_log_file
|
||||
if @stderr_log_file.nil?
|
||||
writable = false
|
||||
path = Redmine::Configuration['scm_stderr_log_file'].presence
|
||||
path ||= Rails.root.join("log/#{Rails.env}.scm.stderr.log").to_s
|
||||
if File.exists?(path)
|
||||
if File.file?(path) && File.writable?(path)
|
||||
writable = true
|
||||
else
|
||||
logger.warn("SCM log file (#{path}) is not writable")
|
||||
end
|
||||
else
|
||||
begin
|
||||
File.open(path, "w") {}
|
||||
writable = true
|
||||
rescue => e
|
||||
logger.warn("SCM log file (#{path}) cannot be created: #{e.message}")
|
||||
end
|
||||
end
|
||||
@stderr_log_file = writable ? path : false
|
||||
end
|
||||
@stderr_log_file || nil
|
||||
end
|
||||
|
||||
def self.shellout(cmd, options = {}, &block)
|
||||
if logger && logger.debug?
|
||||
logger.debug "Shelling out: #{strip_credential(cmd)}"
|
||||
# Capture stderr in a log file
|
||||
if stderr_log_file
|
||||
cmd = "#{cmd} 2>>#{shell_quote(stderr_log_file)}"
|
||||
end
|
||||
end
|
||||
begin
|
||||
mode = "r+"
|
||||
IO.popen(cmd, mode) do |io|
|
||||
io.set_encoding("ASCII-8BIT") if io.respond_to?(:set_encoding)
|
||||
io.close_write unless options[:write_stdin]
|
||||
block.call(io) if block_given?
|
||||
end
|
||||
## If scm command does not exist,
|
||||
## Linux JRuby 1.6.2 (ruby-1.8.7-p330) raises java.io.IOException
|
||||
## in production environment.
|
||||
# rescue Errno::ENOENT => e
|
||||
rescue Exception => e
|
||||
msg = strip_credential(e.message)
|
||||
# The command failed, log it and re-raise
|
||||
logmsg = "SCM command failed, "
|
||||
logmsg += "make sure that your SCM command (e.g. svn) is "
|
||||
logmsg += "in PATH (#{ENV['PATH']})\n"
|
||||
logmsg += "You can configure your scm commands in config/configuration.yml.\n"
|
||||
logmsg += "#{strip_credential(cmd)}\n"
|
||||
logmsg += "with: #{msg}"
|
||||
logger.error(logmsg)
|
||||
raise CommandFailed.new(msg)
|
||||
end
|
||||
end
|
||||
|
||||
# Hides username/password in a given command
|
||||
def self.strip_credential(cmd)
|
||||
q = (Redmine::Platform.mswin? ? '"' : "'")
|
||||
cmd.to_s.gsub(/(\-\-(password|username))\s+(#{q}[^#{q}]+#{q}|[^#{q}]\S+)/, '\\1 xxxx')
|
||||
end
|
||||
|
||||
def strip_credential(cmd)
|
||||
self.class.strip_credential(cmd)
|
||||
end
|
||||
|
||||
def scm_iconv(to, from, str)
|
||||
return nil if str.nil?
|
||||
return str if to == from && str.encoding.to_s == from
|
||||
str.force_encoding(from)
|
||||
begin
|
||||
str.encode(to)
|
||||
rescue Exception => err
|
||||
logger.error("failed to convert from #{from} to #{to}. #{err}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_xml(xml)
|
||||
if RUBY_PLATFORM == 'java'
|
||||
xml = xml.sub(%r{<\?xml[^>]*\?>}, '')
|
||||
end
|
||||
ActiveSupport::XmlMini.parse(xml)
|
||||
end
|
||||
end
|
||||
|
||||
class Entries < Array
|
||||
def sort_by_name
|
||||
dup.sort! {|x,y|
|
||||
if x.kind == y.kind
|
||||
x.name.to_s <=> y.name.to_s
|
||||
else
|
||||
x.kind <=> y.kind
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
def revisions
|
||||
revisions ||= Revisions.new(collect{|entry| entry.lastrev}.compact)
|
||||
end
|
||||
end
|
||||
|
||||
class Info
|
||||
attr_accessor :root_url, :lastrev
|
||||
def initialize(attributes={})
|
||||
self.root_url = attributes[:root_url] if attributes[:root_url]
|
||||
self.lastrev = attributes[:lastrev]
|
||||
end
|
||||
end
|
||||
|
||||
class Entry
|
||||
attr_accessor :name, :path, :kind, :size, :lastrev, :changeset
|
||||
|
||||
def initialize(attributes={})
|
||||
self.name = attributes[:name] if attributes[:name]
|
||||
self.path = attributes[:path] if attributes[:path]
|
||||
self.kind = attributes[:kind] if attributes[:kind]
|
||||
self.size = attributes[:size].to_i if attributes[:size]
|
||||
self.lastrev = attributes[:lastrev]
|
||||
end
|
||||
|
||||
def is_file?
|
||||
'file' == self.kind
|
||||
end
|
||||
|
||||
def is_dir?
|
||||
'dir' == self.kind
|
||||
end
|
||||
|
||||
def is_text?
|
||||
Redmine::MimeType.is_type?('text', name)
|
||||
end
|
||||
|
||||
def author
|
||||
if changeset
|
||||
changeset.author.to_s
|
||||
elsif lastrev
|
||||
Redmine::CodesetUtil.replace_invalid_utf8(lastrev.author.to_s.split('<').first)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Revisions < Array
|
||||
def latest
|
||||
sort {|x,y|
|
||||
unless x.time.nil? or y.time.nil?
|
||||
x.time <=> y.time
|
||||
else
|
||||
0
|
||||
end
|
||||
}.last
|
||||
end
|
||||
end
|
||||
|
||||
class Revision
|
||||
attr_accessor :scmid, :name, :author, :time, :message,
|
||||
:paths, :revision, :branch, :identifier,
|
||||
:parents
|
||||
|
||||
def initialize(attributes={})
|
||||
self.identifier = attributes[:identifier]
|
||||
self.scmid = attributes[:scmid]
|
||||
self.name = attributes[:name] || self.identifier
|
||||
self.author = attributes[:author]
|
||||
self.time = attributes[:time]
|
||||
self.message = attributes[:message] || ""
|
||||
self.paths = attributes[:paths]
|
||||
self.revision = attributes[:revision]
|
||||
self.branch = attributes[:branch]
|
||||
self.parents = attributes[:parents]
|
||||
end
|
||||
|
||||
# Returns the readable identifier.
|
||||
def format_identifier
|
||||
self.identifier.to_s
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
if other.nil?
|
||||
false
|
||||
elsif scmid.present?
|
||||
scmid == other.scmid
|
||||
elsif identifier.present?
|
||||
identifier == other.identifier
|
||||
elsif revision.present?
|
||||
revision == other.revision
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Annotate
|
||||
attr_reader :lines, :revisions
|
||||
|
||||
def initialize
|
||||
@lines = []
|
||||
@revisions = []
|
||||
end
|
||||
|
||||
def add_line(line, revision)
|
||||
@lines << line
|
||||
@revisions << revision
|
||||
end
|
||||
|
||||
def content
|
||||
content = lines.join("\n")
|
||||
end
|
||||
|
||||
def empty?
|
||||
lines.empty?
|
||||
end
|
||||
end
|
||||
|
||||
class Branch < String
|
||||
attr_accessor :revision, :scmid
|
||||
end
|
||||
|
||||
module ScmData
|
||||
def self.binary?(data)
|
||||
unless data.empty?
|
||||
data.count( "^ -~", "^\r\n" ).fdiv(data.size) > 0.3 || data.index( "\x00" )
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
338
lib/redmine/scm/adapters/bazaar_adapter.rb
Normal file
338
lib/redmine/scm/adapters/bazaar_adapter.rb
Normal file
|
@ -0,0 +1,338 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter'
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class BazaarAdapter < AbstractAdapter
|
||||
|
||||
# Bazaar executable name
|
||||
BZR_BIN = Redmine::Configuration['scm_bazaar_command'] || "bzr"
|
||||
|
||||
class << self
|
||||
def client_command
|
||||
@@bin ||= BZR_BIN
|
||||
end
|
||||
|
||||
def sq_bin
|
||||
@@sq_bin ||= shell_quote_command
|
||||
end
|
||||
|
||||
def client_version
|
||||
@@client_version ||= (scm_command_version || [])
|
||||
end
|
||||
|
||||
def client_available
|
||||
!client_version.empty?
|
||||
end
|
||||
|
||||
def scm_command_version
|
||||
scm_version = scm_version_from_command_line.dup.force_encoding('ASCII-8BIT')
|
||||
if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
|
||||
m[2].scan(%r{\d+}).collect(&:to_i)
|
||||
end
|
||||
end
|
||||
|
||||
def scm_version_from_command_line
|
||||
shellout("#{sq_bin} --version") { |io| io.read }.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(url, root_url=nil, login=nil, password=nil, path_encoding=nil)
|
||||
@url = url
|
||||
@root_url = url
|
||||
@path_encoding = 'UTF-8'
|
||||
# do not call *super* for non ASCII repository path
|
||||
end
|
||||
|
||||
def bzr_path_encodig=(encoding)
|
||||
@path_encoding = encoding
|
||||
end
|
||||
|
||||
# Get info about the repository
|
||||
def info
|
||||
cmd_args = %w|revno|
|
||||
cmd_args << bzr_target('')
|
||||
info = nil
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
if io.read =~ %r{^(\d+)\r?$}
|
||||
info = Info.new({:root_url => url,
|
||||
:lastrev => Revision.new({
|
||||
:identifier => $1
|
||||
})
|
||||
})
|
||||
end
|
||||
end
|
||||
info
|
||||
rescue ScmCommandAborted
|
||||
return nil
|
||||
end
|
||||
|
||||
# Returns an Entries collection
|
||||
# or nil if the given path doesn't exist in the repository
|
||||
def entries(path=nil, identifier=nil, options={})
|
||||
path ||= ''
|
||||
entries = Entries.new
|
||||
identifier = -1 unless identifier && identifier.to_i > 0
|
||||
cmd_args = %w|ls -v --show-ids|
|
||||
cmd_args << "-r#{identifier.to_i}"
|
||||
cmd_args << bzr_target(path)
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
prefix_utf8 = "#{url}/#{path}".gsub('\\', '/')
|
||||
logger.debug "PREFIX: #{prefix_utf8}"
|
||||
prefix = scm_iconv(@path_encoding, 'UTF-8', prefix_utf8)
|
||||
prefix.force_encoding('ASCII-8BIT')
|
||||
re = %r{^V\s+(#{Regexp.escape(prefix)})?(\/?)([^\/]+)(\/?)\s+(\S+)\r?$}
|
||||
io.each_line do |line|
|
||||
next unless line =~ re
|
||||
name_locale, slash, revision = $3.strip, $4, $5.strip
|
||||
name = scm_iconv('UTF-8', @path_encoding, name_locale)
|
||||
entries << Entry.new({:name => name,
|
||||
:path => ((path.empty? ? "" : "#{path}/") + name),
|
||||
:kind => (slash.blank? ? 'file' : 'dir'),
|
||||
:size => nil,
|
||||
:lastrev => Revision.new(:revision => revision)
|
||||
})
|
||||
end
|
||||
end
|
||||
if logger && logger.debug?
|
||||
logger.debug("Found #{entries.size} entries in the repository for #{target(path)}")
|
||||
end
|
||||
entries.sort_by_name
|
||||
rescue ScmCommandAborted
|
||||
return nil
|
||||
end
|
||||
|
||||
def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={})
|
||||
path ||= ''
|
||||
identifier_from = (identifier_from and identifier_from.to_i > 0) ? identifier_from.to_i : 'last:1'
|
||||
identifier_to = (identifier_to and identifier_to.to_i > 0) ? identifier_to.to_i : 1
|
||||
revisions = Revisions.new
|
||||
cmd_args = %w|log -v --show-ids|
|
||||
cmd_args << "-r#{identifier_to}..#{identifier_from}"
|
||||
cmd_args << bzr_target(path)
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
revision = nil
|
||||
parsing = nil
|
||||
io.each_line do |line|
|
||||
if line =~ /^----/
|
||||
revisions << revision if revision
|
||||
revision = Revision.new(:paths => [], :message => '')
|
||||
parsing = nil
|
||||
else
|
||||
next unless revision
|
||||
if line =~ /^revno: (\d+)($|\s\[merge\]$)/
|
||||
revision.identifier = $1.to_i
|
||||
elsif line =~ /^committer: (.+)$/
|
||||
revision.author = $1.strip
|
||||
elsif line =~ /^revision-id:(.+)$/
|
||||
revision.scmid = $1.strip
|
||||
elsif line =~ /^timestamp: (.+)$/
|
||||
revision.time = Time.parse($1).localtime
|
||||
elsif line =~ /^ -----/
|
||||
# partial revisions
|
||||
parsing = nil unless parsing == 'message'
|
||||
elsif line =~ /^(message|added|modified|removed|renamed):/
|
||||
parsing = $1
|
||||
elsif line =~ /^ (.*)$/
|
||||
if parsing == 'message'
|
||||
revision.message << "#{$1}\n"
|
||||
else
|
||||
if $1 =~ /^(.*)\s+(\S+)$/
|
||||
path_locale = $1.strip
|
||||
path = scm_iconv('UTF-8', @path_encoding, path_locale)
|
||||
revid = $2
|
||||
case parsing
|
||||
when 'added'
|
||||
revision.paths << {:action => 'A', :path => "/#{path}", :revision => revid}
|
||||
when 'modified'
|
||||
revision.paths << {:action => 'M', :path => "/#{path}", :revision => revid}
|
||||
when 'removed'
|
||||
revision.paths << {:action => 'D', :path => "/#{path}", :revision => revid}
|
||||
when 'renamed'
|
||||
new_path = path.split('=>').last
|
||||
if new_path
|
||||
revision.paths << {:action => 'M', :path => "/#{new_path.strip}",
|
||||
:revision => revid}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
parsing = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
revisions << revision if revision
|
||||
end
|
||||
revisions
|
||||
rescue ScmCommandAborted
|
||||
return nil
|
||||
end
|
||||
|
||||
def diff(path, identifier_from, identifier_to=nil)
|
||||
path ||= ''
|
||||
if identifier_to
|
||||
identifier_to = identifier_to.to_i
|
||||
else
|
||||
identifier_to = identifier_from.to_i - 1
|
||||
end
|
||||
if identifier_from
|
||||
identifier_from = identifier_from.to_i
|
||||
end
|
||||
diff = []
|
||||
cmd_args = %w|diff|
|
||||
cmd_args << "-r#{identifier_to}..#{identifier_from}"
|
||||
cmd_args << bzr_target(path)
|
||||
scm_cmd_no_raise(*cmd_args) do |io|
|
||||
io.each_line do |line|
|
||||
diff << line
|
||||
end
|
||||
end
|
||||
diff
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
cat = nil
|
||||
cmd_args = %w|cat|
|
||||
cmd_args << "-r#{identifier.to_i}" if identifier && identifier.to_i > 0
|
||||
cmd_args << bzr_target(path)
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
io.binmode
|
||||
cat = io.read
|
||||
end
|
||||
cat
|
||||
rescue ScmCommandAborted
|
||||
return nil
|
||||
end
|
||||
|
||||
def annotate(path, identifier=nil)
|
||||
blame = Annotate.new
|
||||
cmd_args = %w|annotate -q --all|
|
||||
cmd_args << "-r#{identifier.to_i}" if identifier && identifier.to_i > 0
|
||||
cmd_args << bzr_target(path)
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
author = nil
|
||||
identifier = nil
|
||||
io.each_line do |line|
|
||||
next unless line =~ %r{^(\d+) ([^|]+)\| (.*)$}
|
||||
rev = $1
|
||||
blame.add_line($3.rstrip,
|
||||
Revision.new(
|
||||
:identifier => rev,
|
||||
:revision => rev,
|
||||
:author => $2.strip
|
||||
))
|
||||
end
|
||||
end
|
||||
blame
|
||||
rescue ScmCommandAborted
|
||||
return nil
|
||||
end
|
||||
|
||||
def self.branch_conf_path(path)
|
||||
bcp = nil
|
||||
m = path.match(%r{^(.*[/\\])\.bzr.*$})
|
||||
if m
|
||||
bcp = m[1]
|
||||
else
|
||||
bcp = path
|
||||
end
|
||||
bcp.gsub!(%r{[\/\\]$}, "")
|
||||
if bcp
|
||||
bcp = File.join(bcp, ".bzr", "branch", "branch.conf")
|
||||
end
|
||||
bcp
|
||||
end
|
||||
|
||||
def append_revisions_only
|
||||
return @aro if ! @aro.nil?
|
||||
@aro = false
|
||||
bcp = self.class.branch_conf_path(url)
|
||||
if bcp && File.exist?(bcp)
|
||||
begin
|
||||
f = File::open(bcp, "r")
|
||||
cnt = 0
|
||||
f.each_line do |line|
|
||||
l = line.chomp.to_s
|
||||
if l =~ /^\s*append_revisions_only\s*=\s*(\w+)\s*$/
|
||||
str_aro = $1
|
||||
if str_aro.upcase == "TRUE"
|
||||
@aro = true
|
||||
cnt += 1
|
||||
elsif str_aro.upcase == "FALSE"
|
||||
@aro = false
|
||||
cnt += 1
|
||||
end
|
||||
if cnt > 1
|
||||
@aro = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
ensure
|
||||
f.close
|
||||
end
|
||||
end
|
||||
@aro
|
||||
end
|
||||
|
||||
def scm_cmd(*args, &block)
|
||||
full_args = []
|
||||
full_args += args
|
||||
full_args_locale = []
|
||||
full_args.map do |e|
|
||||
full_args_locale << scm_iconv(@path_encoding, 'UTF-8', e)
|
||||
end
|
||||
ret = shellout(
|
||||
self.class.sq_bin + ' ' +
|
||||
full_args_locale.map { |e| shell_quote e.to_s }.join(' '),
|
||||
&block
|
||||
)
|
||||
if $? && $?.exitstatus != 0
|
||||
raise ScmCommandAborted, "bzr exited with non-zero status: #{$?.exitstatus}"
|
||||
end
|
||||
ret
|
||||
end
|
||||
private :scm_cmd
|
||||
|
||||
def scm_cmd_no_raise(*args, &block)
|
||||
full_args = []
|
||||
full_args += args
|
||||
full_args_locale = []
|
||||
full_args.map do |e|
|
||||
full_args_locale << scm_iconv(@path_encoding, 'UTF-8', e)
|
||||
end
|
||||
ret = shellout(
|
||||
self.class.sq_bin + ' ' +
|
||||
full_args_locale.map { |e| shell_quote e.to_s }.join(' '),
|
||||
&block
|
||||
)
|
||||
ret
|
||||
end
|
||||
private :scm_cmd_no_raise
|
||||
|
||||
def bzr_target(path)
|
||||
target(path, false)
|
||||
end
|
||||
private :bzr_target
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
25
lib/redmine/scm/adapters/command_failed.rb
Normal file
25
lib/redmine/scm/adapters/command_failed.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class CommandFailed < StandardError #:nodoc:
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
459
lib/redmine/scm/adapters/cvs_adapter.rb
Normal file
459
lib/redmine/scm/adapters/cvs_adapter.rb
Normal file
|
@ -0,0 +1,459 @@
|
|||
# redMine - project management software
|
||||
# Copyright (C) 2006-2007 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter'
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class CvsAdapter < AbstractAdapter
|
||||
|
||||
# CVS executable name
|
||||
CVS_BIN = Redmine::Configuration['scm_cvs_command'] || "cvs"
|
||||
|
||||
class << self
|
||||
def client_command
|
||||
@@bin ||= CVS_BIN
|
||||
end
|
||||
|
||||
def sq_bin
|
||||
@@sq_bin ||= shell_quote_command
|
||||
end
|
||||
|
||||
def client_version
|
||||
@@client_version ||= (scm_command_version || [])
|
||||
end
|
||||
|
||||
def client_available
|
||||
client_version_above?([1, 12])
|
||||
end
|
||||
|
||||
def scm_command_version
|
||||
scm_version = scm_version_from_command_line.dup.force_encoding('ASCII-8BIT')
|
||||
if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}m)
|
||||
m[2].scan(%r{\d+}).collect(&:to_i)
|
||||
end
|
||||
end
|
||||
|
||||
def scm_version_from_command_line
|
||||
shellout("#{sq_bin} --version") { |io| io.read }.to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Guidelines for the input:
|
||||
# url -> the project-path, relative to the cvsroot (eg. module name)
|
||||
# root_url -> the good old, sometimes damned, CVSROOT
|
||||
# login -> unnecessary
|
||||
# password -> unnecessary too
|
||||
def initialize(url, root_url=nil, login=nil, password=nil,
|
||||
path_encoding=nil)
|
||||
@path_encoding = path_encoding.blank? ? 'UTF-8' : path_encoding
|
||||
@url = url
|
||||
# TODO: better Exception here (IllegalArgumentException)
|
||||
raise CommandFailed if root_url.blank?
|
||||
@root_url = root_url
|
||||
|
||||
# These are unused.
|
||||
@login = login if login && !login.empty?
|
||||
@password = (password || "") if @login
|
||||
end
|
||||
|
||||
def path_encoding
|
||||
@path_encoding
|
||||
end
|
||||
|
||||
def info
|
||||
logger.debug "<cvs> info"
|
||||
Info.new({:root_url => @root_url, :lastrev => nil})
|
||||
end
|
||||
|
||||
def get_previous_revision(revision)
|
||||
CvsRevisionHelper.new(revision).prevRev
|
||||
end
|
||||
|
||||
# Returns an Entries collection
|
||||
# or nil if the given path doesn't exist in the repository
|
||||
# this method is used by the repository-browser (aka LIST)
|
||||
def entries(path=nil, identifier=nil, options={})
|
||||
logger.debug "<cvs> entries '#{path}' with identifier '#{identifier}'"
|
||||
path_locale = scm_iconv(@path_encoding, 'UTF-8', path)
|
||||
path_locale.force_encoding("ASCII-8BIT")
|
||||
entries = Entries.new
|
||||
cmd_args = %w|-q rls -e|
|
||||
cmd_args << "-D" << time_to_cvstime_rlog(identifier) if identifier
|
||||
cmd_args << path_with_proj(path)
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
io.each_line() do |line|
|
||||
fields = line.chop.split('/',-1)
|
||||
logger.debug(">>InspectLine #{fields.inspect}")
|
||||
if fields[0]!="D"
|
||||
time = nil
|
||||
# Thu Dec 13 16:27:22 2007
|
||||
time_l = fields[-3].split(' ')
|
||||
if time_l.size == 5 && time_l[4].length == 4
|
||||
begin
|
||||
time = Time.parse(
|
||||
"#{time_l[1]} #{time_l[2]} #{time_l[3]} GMT #{time_l[4]}")
|
||||
rescue
|
||||
end
|
||||
end
|
||||
entries << Entry.new(
|
||||
{
|
||||
:name => scm_iconv('UTF-8', @path_encoding, fields[-5]),
|
||||
#:path => fields[-4].include?(path)?fields[-4]:(path + "/"+ fields[-4]),
|
||||
:path => scm_iconv('UTF-8', @path_encoding, "#{path_locale}/#{fields[-5]}"),
|
||||
:kind => 'file',
|
||||
:size => nil,
|
||||
:lastrev => Revision.new(
|
||||
{
|
||||
:revision => fields[-4],
|
||||
:name => scm_iconv('UTF-8', @path_encoding, fields[-4]),
|
||||
:time => time,
|
||||
:author => ''
|
||||
})
|
||||
})
|
||||
else
|
||||
entries << Entry.new(
|
||||
{
|
||||
:name => scm_iconv('UTF-8', @path_encoding, fields[1]),
|
||||
:path => scm_iconv('UTF-8', @path_encoding, "#{path_locale}/#{fields[1]}"),
|
||||
:kind => 'dir',
|
||||
:size => nil,
|
||||
:lastrev => nil
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
entries.sort_by_name
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
STARTLOG="----------------------------"
|
||||
ENDLOG ="============================================================================="
|
||||
|
||||
# Returns all revisions found between identifier_from and identifier_to
|
||||
# in the repository. both identifier have to be dates or nil.
|
||||
# these method returns nothing but yield every result in block
|
||||
def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={}, &block)
|
||||
path_with_project_utf8 = path_with_proj(path)
|
||||
path_with_project_locale = scm_iconv(@path_encoding, 'UTF-8', path_with_project_utf8)
|
||||
logger.debug "<cvs> revisions path:" +
|
||||
"'#{path}',identifier_from #{identifier_from}, identifier_to #{identifier_to}"
|
||||
cmd_args = %w|-q rlog|
|
||||
cmd_args << "-d" << ">#{time_to_cvstime_rlog(identifier_from)}" if identifier_from
|
||||
cmd_args << path_with_project_utf8
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
state = "entry_start"
|
||||
commit_log = String.new
|
||||
revision = nil
|
||||
date = nil
|
||||
author = nil
|
||||
entry_path = nil
|
||||
entry_name = nil
|
||||
file_state = nil
|
||||
branch_map = nil
|
||||
io.each_line() do |line|
|
||||
if state != "revision" && /^#{ENDLOG}/ =~ line
|
||||
commit_log = String.new
|
||||
revision = nil
|
||||
state = "entry_start"
|
||||
end
|
||||
if state == "entry_start"
|
||||
branch_map = Hash.new
|
||||
if /^RCS file: #{Regexp.escape(root_url_path)}\/#{Regexp.escape(path_with_project_locale)}(.+),v$/ =~ line
|
||||
entry_path = normalize_cvs_path($1)
|
||||
entry_name = normalize_path(File.basename($1))
|
||||
logger.debug("Path #{entry_path} <=> Name #{entry_name}")
|
||||
elsif /^head: (.+)$/ =~ line
|
||||
entry_headRev = $1 #unless entry.nil?
|
||||
elsif /^symbolic names:/ =~ line
|
||||
state = "symbolic" #unless entry.nil?
|
||||
elsif /^#{STARTLOG}/ =~ line
|
||||
commit_log = String.new
|
||||
state = "revision"
|
||||
end
|
||||
next
|
||||
elsif state == "symbolic"
|
||||
if /^(.*):\s(.*)/ =~ (line.strip)
|
||||
branch_map[$1] = $2
|
||||
else
|
||||
state = "tags"
|
||||
next
|
||||
end
|
||||
elsif state == "tags"
|
||||
if /^#{STARTLOG}/ =~ line
|
||||
commit_log = ""
|
||||
state = "revision"
|
||||
elsif /^#{ENDLOG}/ =~ line
|
||||
state = "head"
|
||||
end
|
||||
next
|
||||
elsif state == "revision"
|
||||
if /^#{ENDLOG}/ =~ line || /^#{STARTLOG}/ =~ line
|
||||
if revision
|
||||
revHelper = CvsRevisionHelper.new(revision)
|
||||
revBranch = "HEAD"
|
||||
branch_map.each() do |branch_name, branch_point|
|
||||
if revHelper.is_in_branch_with_symbol(branch_point)
|
||||
revBranch = branch_name
|
||||
end
|
||||
end
|
||||
logger.debug("********** YIELD Revision #{revision}::#{revBranch}")
|
||||
yield Revision.new({
|
||||
:time => date,
|
||||
:author => author,
|
||||
:message => commit_log.chomp,
|
||||
:paths => [{
|
||||
:revision => revision.dup,
|
||||
:branch => revBranch.dup,
|
||||
:path => scm_iconv('UTF-8', @path_encoding, entry_path),
|
||||
:name => scm_iconv('UTF-8', @path_encoding, entry_name),
|
||||
:kind => 'file',
|
||||
:action => file_state
|
||||
}]
|
||||
})
|
||||
end
|
||||
commit_log = String.new
|
||||
revision = nil
|
||||
if /^#{ENDLOG}/ =~ line
|
||||
state = "entry_start"
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
if /^branches: (.+)$/ =~ line
|
||||
# TODO: version.branch = $1
|
||||
elsif /^revision (\d+(?:\.\d+)+).*$/ =~ line
|
||||
revision = $1
|
||||
elsif /^date:\s+(\d+.\d+.\d+\s+\d+:\d+:\d+)/ =~ line
|
||||
date = Time.parse($1)
|
||||
line_utf8 = scm_iconv('UTF-8', options[:log_encoding], line)
|
||||
author_utf8 = /author: ([^;]+)/.match(line_utf8)[1]
|
||||
author = scm_iconv(options[:log_encoding], 'UTF-8', author_utf8)
|
||||
file_state = /state: ([^;]+)/.match(line)[1]
|
||||
# TODO:
|
||||
# linechanges only available in CVS....
|
||||
# maybe a feature our SVN implementation.
|
||||
# I'm sure, they are useful for stats or something else
|
||||
# linechanges =/lines: \+(\d+) -(\d+)/.match(line)
|
||||
# unless linechanges.nil?
|
||||
# version.line_plus = linechanges[1]
|
||||
# version.line_minus = linechanges[2]
|
||||
# else
|
||||
# version.line_plus = 0
|
||||
# version.line_minus = 0
|
||||
# end
|
||||
else
|
||||
commit_log << line unless line =~ /^\*\*\* empty log message \*\*\*/
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue ScmCommandAborted
|
||||
Revisions.new
|
||||
end
|
||||
|
||||
def diff(path, identifier_from, identifier_to=nil)
|
||||
logger.debug "<cvs> diff path:'#{path}'" +
|
||||
",identifier_from #{identifier_from}, identifier_to #{identifier_to}"
|
||||
cmd_args = %w|rdiff -u|
|
||||
cmd_args << "-r#{identifier_to}"
|
||||
cmd_args << "-r#{identifier_from}"
|
||||
cmd_args << path_with_proj(path)
|
||||
diff = []
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
io.each_line do |line|
|
||||
diff << line
|
||||
end
|
||||
end
|
||||
diff
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
identifier = (identifier) ? identifier : "HEAD"
|
||||
logger.debug "<cvs> cat path:'#{path}',identifier #{identifier}"
|
||||
cmd_args = %w|-q co|
|
||||
cmd_args << "-D" << time_to_cvstime(identifier) if identifier
|
||||
cmd_args << "-p" << path_with_proj(path)
|
||||
cat = nil
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
io.binmode
|
||||
cat = io.read
|
||||
end
|
||||
cat
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
def annotate(path, identifier=nil)
|
||||
identifier = (identifier) ? identifier : "HEAD"
|
||||
logger.debug "<cvs> annotate path:'#{path}',identifier #{identifier}"
|
||||
cmd_args = %w|rannotate|
|
||||
cmd_args << "-D" << time_to_cvstime(identifier) if identifier
|
||||
cmd_args << path_with_proj(path)
|
||||
blame = Annotate.new
|
||||
scm_cmd(*cmd_args) do |io|
|
||||
io.each_line do |line|
|
||||
next unless line =~ %r{^([\d\.]+)\s+\(([^\)]+)\s+[^\)]+\):\s(.*)$}
|
||||
blame.add_line(
|
||||
$3.rstrip,
|
||||
Revision.new(
|
||||
:revision => $1,
|
||||
:identifier => nil,
|
||||
:author => $2.strip
|
||||
))
|
||||
end
|
||||
end
|
||||
blame
|
||||
rescue ScmCommandAborted
|
||||
Annotate.new
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns the root url without the connexion string
|
||||
# :pserver:anonymous@foo.bar:/path => /path
|
||||
# :ext:cvsservername:/path => /path
|
||||
def root_url_path
|
||||
root_url.to_s.gsub(%r{^:.+?(?=/)}, '')
|
||||
end
|
||||
|
||||
# convert a date/time into the CVS-format
|
||||
def time_to_cvstime(time)
|
||||
return nil if time.nil?
|
||||
time = Time.now if (time.kind_of?(String) && time == 'HEAD')
|
||||
|
||||
unless time.kind_of? Time
|
||||
time = Time.parse(time)
|
||||
end
|
||||
return time_to_cvstime_rlog(time)
|
||||
end
|
||||
|
||||
def time_to_cvstime_rlog(time)
|
||||
return nil if time.nil?
|
||||
t1 = time.clone.localtime
|
||||
return t1.strftime("%Y-%m-%d %H:%M:%S")
|
||||
end
|
||||
|
||||
def normalize_cvs_path(path)
|
||||
normalize_path(path.gsub(/Attic\//,''))
|
||||
end
|
||||
|
||||
def normalize_path(path)
|
||||
path.sub(/^(\/)*(.*)/,'\2').sub(/(.*)(,v)+/,'\1')
|
||||
end
|
||||
|
||||
def path_with_proj(path)
|
||||
"#{url}#{with_leading_slash(path)}"
|
||||
end
|
||||
private :path_with_proj
|
||||
|
||||
class Revision < Redmine::Scm::Adapters::Revision
|
||||
# Returns the readable identifier
|
||||
def format_identifier
|
||||
revision.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def scm_cmd(*args, &block)
|
||||
full_args = ['-d', root_url]
|
||||
full_args += args
|
||||
full_args_locale = []
|
||||
full_args.map do |e|
|
||||
full_args_locale << scm_iconv(@path_encoding, 'UTF-8', e)
|
||||
end
|
||||
ret = shellout(
|
||||
self.class.sq_bin + ' ' + full_args_locale.map { |e| shell_quote e.to_s }.join(' '),
|
||||
&block
|
||||
)
|
||||
if $? && $?.exitstatus != 0
|
||||
raise ScmCommandAborted, "cvs exited with non-zero status: #{$?.exitstatus}"
|
||||
end
|
||||
ret
|
||||
end
|
||||
private :scm_cmd
|
||||
end
|
||||
|
||||
class CvsRevisionHelper
|
||||
attr_accessor :complete_rev, :revision, :base, :branchid
|
||||
|
||||
def initialize(complete_rev)
|
||||
@complete_rev = complete_rev
|
||||
parseRevision()
|
||||
end
|
||||
|
||||
def branchPoint
|
||||
return @base
|
||||
end
|
||||
|
||||
def branchVersion
|
||||
if isBranchRevision
|
||||
return @base+"."+@branchid
|
||||
end
|
||||
return @base
|
||||
end
|
||||
|
||||
def isBranchRevision
|
||||
!@branchid.nil?
|
||||
end
|
||||
|
||||
def prevRev
|
||||
unless @revision == 0
|
||||
return buildRevision( @revision - 1 )
|
||||
end
|
||||
return buildRevision( @revision )
|
||||
end
|
||||
|
||||
def is_in_branch_with_symbol(branch_symbol)
|
||||
bpieces = branch_symbol.split(".")
|
||||
branch_start = "#{bpieces[0..-3].join(".")}.#{bpieces[-1]}"
|
||||
return ( branchVersion == branch_start )
|
||||
end
|
||||
|
||||
private
|
||||
def buildRevision(rev)
|
||||
if rev == 0
|
||||
if @branchid.nil?
|
||||
@base + ".0"
|
||||
else
|
||||
@base
|
||||
end
|
||||
elsif @branchid.nil?
|
||||
@base + "." + rev.to_s
|
||||
else
|
||||
@base + "." + @branchid + "." + rev.to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Interpretiert die cvs revisionsnummern wie z.b. 1.14 oder 1.3.0.15
|
||||
def parseRevision()
|
||||
pieces = @complete_rev.split(".")
|
||||
@revision = pieces.last.to_i
|
||||
baseSize = 1
|
||||
baseSize += (pieces.size / 2)
|
||||
@base = pieces[0..-baseSize].join(".")
|
||||
if baseSize > 2
|
||||
@branchid = pieces[-2]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
239
lib/redmine/scm/adapters/darcs_adapter.rb
Normal file
239
lib/redmine/scm/adapters/darcs_adapter.rb
Normal file
|
@ -0,0 +1,239 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter'
|
||||
require 'rexml/document'
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class DarcsAdapter < AbstractAdapter
|
||||
# Darcs executable name
|
||||
DARCS_BIN = Redmine::Configuration['scm_darcs_command'] || "darcs"
|
||||
|
||||
class << self
|
||||
def client_command
|
||||
@@bin ||= DARCS_BIN
|
||||
end
|
||||
|
||||
def sq_bin
|
||||
@@sq_bin ||= shell_quote_command
|
||||
end
|
||||
|
||||
def client_version
|
||||
@@client_version ||= (darcs_binary_version || [])
|
||||
end
|
||||
|
||||
def client_available
|
||||
!client_version.empty?
|
||||
end
|
||||
|
||||
def darcs_binary_version
|
||||
darcsversion = darcs_binary_version_from_command_line.dup.force_encoding('ASCII-8BIT')
|
||||
if m = darcsversion.match(%r{\A(.*?)((\d+\.)+\d+)})
|
||||
m[2].scan(%r{\d+}).collect(&:to_i)
|
||||
end
|
||||
end
|
||||
|
||||
def darcs_binary_version_from_command_line
|
||||
shellout("#{sq_bin} --version") { |io| io.read }.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(url, root_url=nil, login=nil, password=nil,
|
||||
path_encoding=nil)
|
||||
@url = url
|
||||
@root_url = url
|
||||
end
|
||||
|
||||
def supports_cat?
|
||||
# cat supported in darcs 2.0.0 and higher
|
||||
self.class.client_version_above?([2, 0, 0])
|
||||
end
|
||||
|
||||
# Get info about the darcs repository
|
||||
def info
|
||||
rev = revisions(nil,nil,nil,{:limit => 1})
|
||||
rev ? Info.new({:root_url => @url, :lastrev => rev.last}) : nil
|
||||
end
|
||||
|
||||
# Returns an Entries collection
|
||||
# or nil if the given path doesn't exist in the repository
|
||||
def entries(path=nil, identifier=nil, options={})
|
||||
path_prefix = (path.blank? ? '' : "#{path}/")
|
||||
if path.blank?
|
||||
path = ( self.class.client_version_above?([2, 2, 0]) ? @url : '.' )
|
||||
end
|
||||
entries = Entries.new
|
||||
cmd = "#{self.class.sq_bin} annotate --repodir #{shell_quote @url} --xml-output"
|
||||
cmd << " --match #{shell_quote("hash #{identifier}")}" if identifier
|
||||
cmd << " #{shell_quote path}"
|
||||
shellout(cmd) do |io|
|
||||
begin
|
||||
doc = REXML::Document.new(io)
|
||||
if doc.root.name == 'directory'
|
||||
doc.elements.each('directory/*') do |element|
|
||||
next unless ['file', 'directory'].include? element.name
|
||||
entries << entry_from_xml(element, path_prefix)
|
||||
end
|
||||
elsif doc.root.name == 'file'
|
||||
entries << entry_from_xml(doc.root, path_prefix)
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
entries.compact!
|
||||
entries.sort_by_name
|
||||
end
|
||||
|
||||
def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={})
|
||||
path = '.' if path.blank?
|
||||
revisions = Revisions.new
|
||||
cmd = "#{self.class.sq_bin} changes --repodir #{shell_quote @url} --xml-output"
|
||||
cmd << " --from-match #{shell_quote("hash #{identifier_from}")}" if identifier_from
|
||||
cmd << " --last #{options[:limit].to_i}" if options[:limit]
|
||||
shellout(cmd) do |io|
|
||||
begin
|
||||
doc = REXML::Document.new(io)
|
||||
doc.elements.each("changelog/patch") do |patch|
|
||||
message = patch.elements['name'].text
|
||||
message << "\n" + patch.elements['comment'].text.gsub(/\*\*\*END OF DESCRIPTION\*\*\*.*\z/m, '') if patch.elements['comment']
|
||||
revisions << Revision.new({:identifier => nil,
|
||||
:author => patch.attributes['author'],
|
||||
:scmid => patch.attributes['hash'],
|
||||
:time => Time.parse(patch.attributes['local_date']),
|
||||
:message => message,
|
||||
:paths => (options[:with_path] ? get_paths_for_patch(patch.attributes['hash']) : nil)
|
||||
})
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
revisions
|
||||
end
|
||||
|
||||
def diff(path, identifier_from, identifier_to=nil)
|
||||
path = '*' if path.blank?
|
||||
cmd = "#{self.class.sq_bin} diff --repodir #{shell_quote @url}"
|
||||
if identifier_to.nil?
|
||||
cmd << " --match #{shell_quote("hash #{identifier_from}")}"
|
||||
else
|
||||
cmd << " --to-match #{shell_quote("hash #{identifier_from}")}"
|
||||
cmd << " --from-match #{shell_quote("hash #{identifier_to}")}"
|
||||
end
|
||||
cmd << " -u #{shell_quote path}"
|
||||
diff = []
|
||||
shellout(cmd) do |io|
|
||||
io.each_line do |line|
|
||||
diff << line
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
diff
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
cmd = "#{self.class.sq_bin} show content --repodir #{shell_quote @url}"
|
||||
cmd << " --match #{shell_quote("hash #{identifier}")}" if identifier
|
||||
cmd << " #{shell_quote path}"
|
||||
cat = nil
|
||||
shellout(cmd) do |io|
|
||||
io.binmode
|
||||
cat = io.read
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
cat
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns an Entry from the given XML element
|
||||
# or nil if the entry was deleted
|
||||
def entry_from_xml(element, path_prefix)
|
||||
modified_element = element.elements['modified']
|
||||
if modified_element.elements['modified_how'].text.match(/removed/)
|
||||
return nil
|
||||
end
|
||||
|
||||
Entry.new({:name => element.attributes['name'],
|
||||
:path => path_prefix + element.attributes['name'],
|
||||
:kind => element.name == 'file' ? 'file' : 'dir',
|
||||
:size => nil,
|
||||
:lastrev => Revision.new({
|
||||
:identifier => nil,
|
||||
:scmid => modified_element.elements['patch'].attributes['hash']
|
||||
})
|
||||
})
|
||||
end
|
||||
|
||||
def get_paths_for_patch(hash)
|
||||
paths = get_paths_for_patch_raw(hash)
|
||||
if self.class.client_version_above?([2, 4])
|
||||
orig_paths = paths
|
||||
paths = []
|
||||
add_paths = []
|
||||
add_paths_name = []
|
||||
mod_paths = []
|
||||
other_paths = []
|
||||
orig_paths.each do |path|
|
||||
if path[:action] == 'A'
|
||||
add_paths << path
|
||||
add_paths_name << path[:path]
|
||||
elsif path[:action] == 'M'
|
||||
mod_paths << path
|
||||
else
|
||||
other_paths << path
|
||||
end
|
||||
end
|
||||
add_paths_name.each do |add_path|
|
||||
mod_paths.delete_if { |m| m[:path] == add_path }
|
||||
end
|
||||
paths.concat add_paths
|
||||
paths.concat mod_paths
|
||||
paths.concat other_paths
|
||||
end
|
||||
paths
|
||||
end
|
||||
|
||||
# Retrieve changed paths for a single patch
|
||||
def get_paths_for_patch_raw(hash)
|
||||
cmd = "#{self.class.sq_bin} annotate --repodir #{shell_quote @url} --summary --xml-output"
|
||||
cmd << " --match #{shell_quote("hash #{hash}")} "
|
||||
paths = []
|
||||
shellout(cmd) do |io|
|
||||
begin
|
||||
# Darcs xml output has multiple root elements in this case (tested with darcs 1.0.7)
|
||||
# A root element is added so that REXML doesn't raise an error
|
||||
doc = REXML::Document.new("<fake_root>" + io.read + "</fake_root>")
|
||||
doc.elements.each('fake_root/summary/*') do |modif|
|
||||
paths << {:action => modif.name[0,1].upcase,
|
||||
:path => "/" + modif.text.chomp.gsub(/^\s*/, '')
|
||||
}
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
paths
|
||||
rescue CommandFailed
|
||||
paths
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
118
lib/redmine/scm/adapters/filesystem_adapter.rb
Normal file
118
lib/redmine/scm/adapters/filesystem_adapter.rb
Normal file
|
@ -0,0 +1,118 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# FileSystem adapter
|
||||
# File written by Paul Rivier, at Demotera.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter'
|
||||
require 'find'
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class FilesystemAdapter < AbstractAdapter
|
||||
|
||||
class << self
|
||||
def client_available
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(url, root_url=nil, login=nil, password=nil,
|
||||
path_encoding=nil)
|
||||
@url = with_trailling_slash(url)
|
||||
@path_encoding = path_encoding.blank? ? 'UTF-8' : path_encoding
|
||||
end
|
||||
|
||||
def path_encoding
|
||||
@path_encoding
|
||||
end
|
||||
|
||||
def format_path_ends(path, leading=true, trailling=true)
|
||||
path = leading ? with_leading_slash(path) :
|
||||
without_leading_slash(path)
|
||||
trailling ? with_trailling_slash(path) :
|
||||
without_trailling_slash(path)
|
||||
end
|
||||
|
||||
def info
|
||||
info = Info.new({:root_url => target(),
|
||||
:lastrev => nil
|
||||
})
|
||||
info
|
||||
rescue CommandFailed
|
||||
return nil
|
||||
end
|
||||
|
||||
def entries(path="", identifier=nil, options={})
|
||||
entries = Entries.new
|
||||
trgt_utf8 = target(path)
|
||||
trgt = scm_iconv(@path_encoding, 'UTF-8', trgt_utf8)
|
||||
Dir.new(trgt).each do |e1|
|
||||
e_utf8 = scm_iconv('UTF-8', @path_encoding, e1)
|
||||
next if e_utf8.blank?
|
||||
relative_path_utf8 = format_path_ends(
|
||||
(format_path_ends(path,false,true) + e_utf8),false,false)
|
||||
t1_utf8 = target(relative_path_utf8)
|
||||
t1 = scm_iconv(@path_encoding, 'UTF-8', t1_utf8)
|
||||
relative_path = scm_iconv(@path_encoding, 'UTF-8', relative_path_utf8)
|
||||
e1 = scm_iconv(@path_encoding, 'UTF-8', e_utf8)
|
||||
if File.exist?(t1) and # paranoid test
|
||||
%w{file directory}.include?(File.ftype(t1)) and # avoid special types
|
||||
not File.basename(e1).match(/^\.+$/) # avoid . and ..
|
||||
p1 = File.readable?(t1) ? relative_path : ""
|
||||
utf_8_path = scm_iconv('UTF-8', @path_encoding, p1)
|
||||
entries <<
|
||||
Entry.new({ :name => scm_iconv('UTF-8', @path_encoding, File.basename(e1)),
|
||||
# below : list unreadable files, but dont link them.
|
||||
:path => utf_8_path,
|
||||
:kind => (File.directory?(t1) ? 'dir' : 'file'),
|
||||
:size => (File.directory?(t1) ? nil : [File.size(t1)].pack('l').unpack('L').first),
|
||||
:lastrev =>
|
||||
Revision.new({:time => (File.mtime(t1)) })
|
||||
})
|
||||
end
|
||||
end
|
||||
entries.sort_by_name
|
||||
rescue => err
|
||||
logger.error "scm: filesystem: error: #{err.message}"
|
||||
raise CommandFailed.new(err.message)
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
p = scm_iconv(@path_encoding, 'UTF-8', target(path))
|
||||
File.new(p, "rb").read
|
||||
rescue => err
|
||||
logger.error "scm: filesystem: error: #{err.message}"
|
||||
raise CommandFailed.new(err.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# AbstractAdapter::target is implicitly made to quote paths.
|
||||
# Here we do not shell-out, so we do not want quotes.
|
||||
def target(path=nil)
|
||||
# Prevent the use of ..
|
||||
if path and !path.match(/(^|\/)\.\.(\/|$)/)
|
||||
return "#{self.url}#{without_leading_slash(path)}"
|
||||
end
|
||||
return self.url
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
411
lib/redmine/scm/adapters/git_adapter.rb
Normal file
411
lib/redmine/scm/adapters/git_adapter.rb
Normal file
|
@ -0,0 +1,411 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter'
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class GitAdapter < AbstractAdapter
|
||||
|
||||
# Git executable name
|
||||
GIT_BIN = Redmine::Configuration['scm_git_command'] || "git"
|
||||
|
||||
class GitBranch < Branch
|
||||
attr_accessor :is_default
|
||||
end
|
||||
|
||||
class << self
|
||||
def client_command
|
||||
@@bin ||= GIT_BIN
|
||||
end
|
||||
|
||||
def sq_bin
|
||||
@@sq_bin ||= shell_quote_command
|
||||
end
|
||||
|
||||
def client_version
|
||||
@@client_version ||= (scm_command_version || [])
|
||||
end
|
||||
|
||||
def client_available
|
||||
!client_version.empty?
|
||||
end
|
||||
|
||||
def scm_command_version
|
||||
scm_version = scm_version_from_command_line.dup.force_encoding('ASCII-8BIT')
|
||||
if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
|
||||
m[2].scan(%r{\d+}).collect(&:to_i)
|
||||
end
|
||||
end
|
||||
|
||||
def scm_version_from_command_line
|
||||
shellout("#{sq_bin} --version") { |io| io.read }.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(url, root_url=nil, login=nil, password=nil, path_encoding=nil)
|
||||
super
|
||||
@path_encoding = path_encoding.blank? ? 'UTF-8' : path_encoding
|
||||
end
|
||||
|
||||
def path_encoding
|
||||
@path_encoding
|
||||
end
|
||||
|
||||
def info
|
||||
begin
|
||||
Info.new(:root_url => url, :lastrev => lastrev('',nil))
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def branches
|
||||
return @branches if @branches
|
||||
@branches = []
|
||||
cmd_args = %w|branch --no-color --verbose --no-abbrev|
|
||||
git_cmd(cmd_args) do |io|
|
||||
io.each_line do |line|
|
||||
branch_rev = line.match('\s*(\*?)\s*(.*?)\s*([0-9a-f]{40}).*$')
|
||||
bran = GitBranch.new(branch_rev[2])
|
||||
bran.revision = branch_rev[3]
|
||||
bran.scmid = branch_rev[3]
|
||||
bran.is_default = ( branch_rev[1] == '*' )
|
||||
@branches << bran
|
||||
end
|
||||
end
|
||||
@branches.sort!
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
def tags
|
||||
return @tags if @tags
|
||||
@tags = []
|
||||
cmd_args = %w|tag|
|
||||
git_cmd(cmd_args) do |io|
|
||||
@tags = io.readlines.sort!.map{|t| t.strip}
|
||||
end
|
||||
@tags
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
def default_branch
|
||||
bras = self.branches
|
||||
return nil if bras.nil?
|
||||
default_bras = bras.select{|x| x.is_default == true}
|
||||
return default_bras.first.to_s if ! default_bras.empty?
|
||||
master_bras = bras.select{|x| x.to_s == 'master'}
|
||||
master_bras.empty? ? bras.first.to_s : 'master'
|
||||
end
|
||||
|
||||
def entry(path=nil, identifier=nil)
|
||||
parts = path.to_s.split(%r{[\/\\]}).select {|n| !n.blank?}
|
||||
search_path = parts[0..-2].join('/')
|
||||
search_name = parts[-1]
|
||||
if search_path.blank? && search_name.blank?
|
||||
# Root entry
|
||||
Entry.new(:path => '', :kind => 'dir')
|
||||
else
|
||||
# Search for the entry in the parent directory
|
||||
es = entries(search_path, identifier,
|
||||
options = {:report_last_commit => false})
|
||||
es ? es.detect {|e| e.name == search_name} : nil
|
||||
end
|
||||
end
|
||||
|
||||
def entries(path=nil, identifier=nil, options={})
|
||||
path ||= ''
|
||||
p = scm_iconv(@path_encoding, 'UTF-8', path)
|
||||
entries = Entries.new
|
||||
cmd_args = %w|ls-tree -l|
|
||||
cmd_args << "HEAD:#{p}" if identifier.nil?
|
||||
cmd_args << "#{identifier}:#{p}" if identifier
|
||||
git_cmd(cmd_args) do |io|
|
||||
io.each_line do |line|
|
||||
e = line.chomp.to_s
|
||||
if e =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/
|
||||
type = $1
|
||||
sha = $2
|
||||
size = $3
|
||||
name = $4.force_encoding(@path_encoding)
|
||||
full_path = p.empty? ? name : "#{p}/#{name}"
|
||||
n = scm_iconv('UTF-8', @path_encoding, name)
|
||||
full_p = scm_iconv('UTF-8', @path_encoding, full_path)
|
||||
entries << Entry.new({:name => n,
|
||||
:path => full_p,
|
||||
:kind => (type == "tree") ? 'dir' : 'file',
|
||||
:size => (type == "tree") ? nil : size,
|
||||
:lastrev => options[:report_last_commit] ?
|
||||
lastrev(full_path, identifier) : Revision.new
|
||||
}) unless entries.detect{|entry| entry.name == name}
|
||||
end
|
||||
end
|
||||
end
|
||||
entries.sort_by_name
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
def lastrev(path, rev)
|
||||
return nil if path.nil?
|
||||
cmd_args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1|
|
||||
cmd_args << '--no-renames' if self.class.client_version_above?([2, 9])
|
||||
cmd_args << rev if rev
|
||||
cmd_args << "--" << path unless path.empty?
|
||||
lines = []
|
||||
git_cmd(cmd_args) { |io| lines = io.readlines }
|
||||
begin
|
||||
id = lines[0].split[1]
|
||||
author = lines[1].match('Author:\s+(.*)$')[1]
|
||||
time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1])
|
||||
|
||||
Revision.new({
|
||||
:identifier => id,
|
||||
:scmid => id,
|
||||
:author => author,
|
||||
:time => time,
|
||||
:message => nil,
|
||||
:paths => nil
|
||||
})
|
||||
rescue NoMethodError => e
|
||||
logger.error("The revision '#{path}' has a wrong format")
|
||||
return nil
|
||||
end
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
def revisions(path, identifier_from, identifier_to, options={})
|
||||
revs = Revisions.new
|
||||
cmd_args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller --parents --stdin|
|
||||
cmd_args << '--no-renames' if self.class.client_version_above?([2, 9])
|
||||
cmd_args << "--reverse" if options[:reverse]
|
||||
cmd_args << "-n" << "#{options[:limit].to_i}" if options[:limit]
|
||||
cmd_args << "--" << scm_iconv(@path_encoding, 'UTF-8', path) if path && !path.empty?
|
||||
revisions = []
|
||||
if identifier_from || identifier_to
|
||||
revisions << ""
|
||||
revisions[0] << "#{identifier_from}.." if identifier_from
|
||||
revisions[0] << "#{identifier_to}" if identifier_to
|
||||
else
|
||||
unless options[:includes].blank?
|
||||
revisions += options[:includes]
|
||||
end
|
||||
unless options[:excludes].blank?
|
||||
revisions += options[:excludes].map{|r| "^#{r}"}
|
||||
end
|
||||
end
|
||||
|
||||
git_cmd(cmd_args, {:write_stdin => true}) do |io|
|
||||
io.binmode
|
||||
io.puts(revisions.join("\n"))
|
||||
io.close_write
|
||||
files=[]
|
||||
changeset = {}
|
||||
parsing_descr = 0 #0: not parsing desc or files, 1: parsing desc, 2: parsing files
|
||||
|
||||
io.each_line do |line|
|
||||
if line =~ /^commit ([0-9a-f]{40})(( [0-9a-f]{40})*)$/
|
||||
key = "commit"
|
||||
value = $1
|
||||
parents_str = $2
|
||||
if (parsing_descr == 1 || parsing_descr == 2)
|
||||
parsing_descr = 0
|
||||
revision = Revision.new({
|
||||
:identifier => changeset[:commit],
|
||||
:scmid => changeset[:commit],
|
||||
:author => changeset[:author],
|
||||
:time => Time.parse(changeset[:date]),
|
||||
:message => changeset[:description],
|
||||
:paths => files,
|
||||
:parents => changeset[:parents]
|
||||
})
|
||||
if block_given?
|
||||
yield revision
|
||||
else
|
||||
revs << revision
|
||||
end
|
||||
changeset = {}
|
||||
files = []
|
||||
end
|
||||
changeset[:commit] = $1
|
||||
unless parents_str.nil? or parents_str == ""
|
||||
changeset[:parents] = parents_str.strip.split(' ')
|
||||
end
|
||||
elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/
|
||||
key = $1
|
||||
value = $2
|
||||
if key == "Author"
|
||||
changeset[:author] = value
|
||||
elsif key == "CommitDate"
|
||||
changeset[:date] = value
|
||||
end
|
||||
elsif (parsing_descr == 0) && line.chomp.to_s == ""
|
||||
parsing_descr = 1
|
||||
changeset[:description] = ""
|
||||
elsif (parsing_descr == 1 || parsing_descr == 2) \
|
||||
&& line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/
|
||||
parsing_descr = 2
|
||||
fileaction = $1
|
||||
filepath = $2
|
||||
p = scm_iconv('UTF-8', @path_encoding, filepath)
|
||||
files << {:action => fileaction, :path => p}
|
||||
elsif (parsing_descr == 1 || parsing_descr == 2) \
|
||||
&& line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/
|
||||
parsing_descr = 2
|
||||
fileaction = $1
|
||||
filepath = $3
|
||||
p = scm_iconv('UTF-8', @path_encoding, filepath)
|
||||
files << {:action => fileaction, :path => p}
|
||||
elsif (parsing_descr == 1) && line.chomp.to_s == ""
|
||||
parsing_descr = 2
|
||||
elsif (parsing_descr == 1)
|
||||
changeset[:description] << line[4..-1]
|
||||
end
|
||||
end
|
||||
|
||||
if changeset[:commit]
|
||||
revision = Revision.new({
|
||||
:identifier => changeset[:commit],
|
||||
:scmid => changeset[:commit],
|
||||
:author => changeset[:author],
|
||||
:time => Time.parse(changeset[:date]),
|
||||
:message => changeset[:description],
|
||||
:paths => files,
|
||||
:parents => changeset[:parents]
|
||||
})
|
||||
if block_given?
|
||||
yield revision
|
||||
else
|
||||
revs << revision
|
||||
end
|
||||
end
|
||||
end
|
||||
revs
|
||||
rescue ScmCommandAborted => e
|
||||
err_msg = "git log error: #{e.message}"
|
||||
logger.error(err_msg)
|
||||
if block_given?
|
||||
raise CommandFailed, err_msg
|
||||
else
|
||||
revs
|
||||
end
|
||||
end
|
||||
|
||||
def diff(path, identifier_from, identifier_to=nil)
|
||||
path ||= ''
|
||||
cmd_args = []
|
||||
if identifier_to
|
||||
cmd_args << "diff" << "--no-color" << identifier_to << identifier_from
|
||||
else
|
||||
cmd_args << "show" << "--no-color" << identifier_from
|
||||
end
|
||||
cmd_args << '--no-renames' if self.class.client_version_above?([2, 9])
|
||||
cmd_args << "--" << scm_iconv(@path_encoding, 'UTF-8', path) unless path.empty?
|
||||
diff = []
|
||||
git_cmd(cmd_args) do |io|
|
||||
io.each_line do |line|
|
||||
diff << line
|
||||
end
|
||||
end
|
||||
diff
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
def annotate(path, identifier=nil)
|
||||
identifier = 'HEAD' if identifier.blank?
|
||||
cmd_args = %w|blame --encoding=UTF-8|
|
||||
cmd_args << "-p" << identifier << "--" << scm_iconv(@path_encoding, 'UTF-8', path)
|
||||
blame = Annotate.new
|
||||
content = nil
|
||||
git_cmd(cmd_args) { |io| io.binmode; content = io.read }
|
||||
# git annotates binary files
|
||||
return nil if ScmData.binary?(content)
|
||||
identifier = ''
|
||||
# git shows commit author on the first occurrence only
|
||||
authors_by_commit = {}
|
||||
content.split("\n").each do |line|
|
||||
if line =~ /^([0-9a-f]{39,40})\s.*/
|
||||
identifier = $1
|
||||
elsif line =~ /^author (.+)/
|
||||
authors_by_commit[identifier] = $1.strip
|
||||
elsif line =~ /^\t(.*)/
|
||||
blame.add_line($1, Revision.new(
|
||||
:identifier => identifier,
|
||||
:revision => identifier,
|
||||
:scmid => identifier,
|
||||
:author => authors_by_commit[identifier]
|
||||
))
|
||||
identifier = ''
|
||||
author = ''
|
||||
end
|
||||
end
|
||||
blame
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
if identifier.nil?
|
||||
identifier = 'HEAD'
|
||||
end
|
||||
cmd_args = %w|show --no-color|
|
||||
cmd_args << "#{identifier}:#{scm_iconv(@path_encoding, 'UTF-8', path)}"
|
||||
cat = nil
|
||||
git_cmd(cmd_args) do |io|
|
||||
io.binmode
|
||||
cat = io.read
|
||||
end
|
||||
cat
|
||||
rescue ScmCommandAborted
|
||||
nil
|
||||
end
|
||||
|
||||
class Revision < Redmine::Scm::Adapters::Revision
|
||||
# Returns the readable identifier
|
||||
def format_identifier
|
||||
identifier[0,8]
|
||||
end
|
||||
end
|
||||
|
||||
def git_cmd(args, options = {}, &block)
|
||||
repo_path = root_url || url
|
||||
full_args = ['--git-dir', repo_path]
|
||||
if self.class.client_version_above?([1, 7, 2])
|
||||
full_args << '-c' << 'core.quotepath=false'
|
||||
full_args << '-c' << 'log.decorate=no'
|
||||
end
|
||||
full_args += args
|
||||
ret = shellout(
|
||||
self.class.sq_bin + ' ' + full_args.map { |e| shell_quote e.to_s }.join(' '),
|
||||
options,
|
||||
&block
|
||||
)
|
||||
if $? && $?.exitstatus != 0
|
||||
raise ScmCommandAborted, "git exited with non-zero status: #{$?.exitstatus}"
|
||||
end
|
||||
ret
|
||||
end
|
||||
private :git_cmd
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
12
lib/redmine/scm/adapters/mercurial/hg-template-1.0.tmpl
Normal file
12
lib/redmine/scm/adapters/mercurial/hg-template-1.0.tmpl
Normal file
|
@ -0,0 +1,12 @@
|
|||
changeset = 'This template must be used with --debug option\n'
|
||||
changeset_quiet = 'This template must be used with --debug option\n'
|
||||
changeset_verbose = 'This template must be used with --debug option\n'
|
||||
changeset_debug = '<logentry revision="{rev}" node="{node}">\n<author>{author|escape}</author>\n<date>{date|isodatesec}</date>\n<paths>\n{file_mods}{file_adds}{file_dels}{file_copies}</paths>\n<msg>{desc|escape}</msg>\n<parents>\n{parents}</parents>\n</logentry>\n\n'
|
||||
|
||||
file_mod = '<path action="M">{file_mod|urlescape}</path>\n'
|
||||
file_add = '<path action="A">{file_add|urlescape}</path>\n'
|
||||
file_del = '<path action="D">{file_del|urlescape}</path>\n'
|
||||
file_copy = '<path-copied copyfrom-path="{source|urlescape}">{name|urlescape}</path-copied>\n'
|
||||
parent = '<parent>{node}</parent>\n'
|
||||
header='<?xml version="1.0" encoding="UTF-8" ?>\n<log>\n\n'
|
||||
# footer="</log>"
|
226
lib/redmine/scm/adapters/mercurial/redminehelper.py
Normal file
226
lib/redmine/scm/adapters/mercurial/redminehelper.py
Normal file
|
@ -0,0 +1,226 @@
|
|||
# redminehelper: Redmine helper extension for Mercurial
|
||||
#
|
||||
# Copyright 2010 Alessio Franceschelli (alefranz.net)
|
||||
# Copyright 2010-2011 Yuya Nishihara <yuya@tcha.org>
|
||||
#
|
||||
# This software may be used and distributed according to the terms of the
|
||||
# GNU General Public License version 2 or any later version.
|
||||
"""helper commands for Redmine to reduce the number of hg calls
|
||||
|
||||
To test this extension, please try::
|
||||
|
||||
$ hg --config extensions.redminehelper=redminehelper.py rhsummary
|
||||
|
||||
I/O encoding:
|
||||
|
||||
:file path: urlencoded, raw string
|
||||
:tag name: utf-8
|
||||
:branch name: utf-8
|
||||
:node: hex string
|
||||
|
||||
Output example of rhsummary::
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<rhsummary>
|
||||
<repository root="/foo/bar">
|
||||
<tip revision="1234" node="abcdef0123..."/>
|
||||
<tag revision="123" node="34567abc..." name="1.1.1"/>
|
||||
<branch .../>
|
||||
...
|
||||
</repository>
|
||||
</rhsummary>
|
||||
|
||||
Output example of rhmanifest::
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<rhmanifest>
|
||||
<repository root="/foo/bar">
|
||||
<manifest revision="1234" path="lib">
|
||||
<file name="diff.rb" revision="123" node="34567abc..." time="12345"
|
||||
size="100"/>
|
||||
...
|
||||
<dir name="redmine"/>
|
||||
...
|
||||
</manifest>
|
||||
</repository>
|
||||
</rhmanifest>
|
||||
"""
|
||||
import re, time, cgi, urllib
|
||||
from mercurial import cmdutil, commands, node, error, hg
|
||||
|
||||
cmdtable = {}
|
||||
command = cmdutil.command(cmdtable)
|
||||
|
||||
_x = cgi.escape
|
||||
_u = lambda s: cgi.escape(urllib.quote(s))
|
||||
|
||||
def _tip(ui, repo):
|
||||
# see mercurial/commands.py:tip
|
||||
def tiprev():
|
||||
try:
|
||||
return len(repo) - 1
|
||||
except TypeError: # Mercurial < 1.1
|
||||
return repo.changelog.count() - 1
|
||||
tipctx = repo.changectx(tiprev())
|
||||
ui.write('<tip revision="%d" node="%s"/>\n'
|
||||
% (tipctx.rev(), _x(node.hex(tipctx.node()))))
|
||||
|
||||
_SPECIAL_TAGS = ('tip',)
|
||||
|
||||
def _tags(ui, repo):
|
||||
# see mercurial/commands.py:tags
|
||||
for t, n in reversed(repo.tagslist()):
|
||||
if t in _SPECIAL_TAGS:
|
||||
continue
|
||||
try:
|
||||
r = repo.changelog.rev(n)
|
||||
except error.LookupError:
|
||||
continue
|
||||
ui.write('<tag revision="%d" node="%s" name="%s"/>\n'
|
||||
% (r, _x(node.hex(n)), _x(t)))
|
||||
|
||||
def _branches(ui, repo):
|
||||
# see mercurial/commands.py:branches
|
||||
def iterbranches():
|
||||
if getattr(repo, 'branchtags', None) is not None:
|
||||
# Mercurial < 2.9
|
||||
for t, n in repo.branchtags().iteritems():
|
||||
yield t, n, repo.changelog.rev(n)
|
||||
else:
|
||||
for tag, heads, tip, isclosed in repo.branchmap().iterbranches():
|
||||
yield tag, tip, repo.changelog.rev(tip)
|
||||
def branchheads(branch):
|
||||
try:
|
||||
return repo.branchheads(branch, closed=False)
|
||||
except TypeError: # Mercurial < 1.2
|
||||
return repo.branchheads(branch)
|
||||
for t, n, r in sorted(iterbranches(), key=lambda e: e[2], reverse=True):
|
||||
if repo.lookup(r) in branchheads(t):
|
||||
ui.write('<branch revision="%d" node="%s" name="%s"/>\n'
|
||||
% (r, _x(node.hex(n)), _x(t)))
|
||||
|
||||
def _manifest(ui, repo, path, rev):
|
||||
ctx = repo.changectx(rev)
|
||||
ui.write('<manifest revision="%d" path="%s">\n'
|
||||
% (ctx.rev(), _u(path)))
|
||||
|
||||
known = set()
|
||||
pathprefix = (path.rstrip('/') + '/').lstrip('/')
|
||||
for f, n in sorted(ctx.manifest().iteritems(), key=lambda e: e[0]):
|
||||
if not f.startswith(pathprefix):
|
||||
continue
|
||||
name = re.sub(r'/.*', '/', f[len(pathprefix):])
|
||||
if name in known:
|
||||
continue
|
||||
known.add(name)
|
||||
|
||||
if name.endswith('/'):
|
||||
ui.write('<dir name="%s"/>\n'
|
||||
% _x(urllib.quote(name[:-1])))
|
||||
else:
|
||||
fctx = repo.filectx(f, fileid=n)
|
||||
tm, tzoffset = fctx.date()
|
||||
ui.write('<file name="%s" revision="%d" node="%s" '
|
||||
'time="%d" size="%d"/>\n'
|
||||
% (_u(name), fctx.rev(), _x(node.hex(fctx.node())),
|
||||
tm, fctx.size(), ))
|
||||
|
||||
ui.write('</manifest>\n')
|
||||
|
||||
@command('rhannotate',
|
||||
[('r', 'rev', '', 'revision'),
|
||||
('u', 'user', None, 'list the author (long with -v)'),
|
||||
('n', 'number', None, 'list the revision number (default)'),
|
||||
('c', 'changeset', None, 'list the changeset'),
|
||||
],
|
||||
'hg rhannotate [-r REV] [-u] [-n] [-c] FILE...')
|
||||
def rhannotate(ui, repo, *pats, **opts):
|
||||
rev = urllib.unquote_plus(opts.pop('rev', None))
|
||||
opts['rev'] = rev
|
||||
return commands.annotate(ui, repo, *map(urllib.unquote_plus, pats), **opts)
|
||||
|
||||
@command('rhcat',
|
||||
[('r', 'rev', '', 'revision')],
|
||||
'hg rhcat ([-r REV] ...) FILE...')
|
||||
def rhcat(ui, repo, file1, *pats, **opts):
|
||||
rev = urllib.unquote_plus(opts.pop('rev', None))
|
||||
opts['rev'] = rev
|
||||
return commands.cat(ui, repo, urllib.unquote_plus(file1), *map(urllib.unquote_plus, pats), **opts)
|
||||
|
||||
@command('rhdiff',
|
||||
[('r', 'rev', [], 'revision'),
|
||||
('c', 'change', '', 'change made by revision')],
|
||||
'hg rhdiff ([-c REV] | [-r REV] ...) [FILE]...')
|
||||
def rhdiff(ui, repo, *pats, **opts):
|
||||
"""diff repository (or selected files)"""
|
||||
change = opts.pop('change', None)
|
||||
if change: # add -c option for Mercurial<1.1
|
||||
base = repo.changectx(change).parents()[0].rev()
|
||||
opts['rev'] = [str(base), change]
|
||||
opts['nodates'] = True
|
||||
return commands.diff(ui, repo, *map(urllib.unquote_plus, pats), **opts)
|
||||
|
||||
@command('rhlog',
|
||||
[
|
||||
('r', 'rev', [], 'show the specified revision'),
|
||||
('b', 'branch', [],
|
||||
'show changesets within the given named branch'),
|
||||
('l', 'limit', '',
|
||||
'limit number of changes displayed'),
|
||||
('d', 'date', '',
|
||||
'show revisions matching date spec'),
|
||||
('u', 'user', [],
|
||||
'revisions committed by user'),
|
||||
('', 'from', '',
|
||||
''),
|
||||
('', 'to', '',
|
||||
''),
|
||||
('', 'rhbranch', '',
|
||||
''),
|
||||
('', 'template', '',
|
||||
'display with template')],
|
||||
'hg rhlog [OPTION]... [FILE]')
|
||||
def rhlog(ui, repo, *pats, **opts):
|
||||
rev = opts.pop('rev')
|
||||
bra0 = opts.pop('branch')
|
||||
from_rev = urllib.unquote_plus(opts.pop('from', None))
|
||||
to_rev = urllib.unquote_plus(opts.pop('to' , None))
|
||||
bra = urllib.unquote_plus(opts.pop('rhbranch', None))
|
||||
from_rev = from_rev.replace('"', '\\"')
|
||||
to_rev = to_rev.replace('"', '\\"')
|
||||
if hg.util.version() >= '1.6':
|
||||
opts['rev'] = ['"%s":"%s"' % (from_rev, to_rev)]
|
||||
else:
|
||||
opts['rev'] = ['%s:%s' % (from_rev, to_rev)]
|
||||
opts['branch'] = [bra]
|
||||
return commands.log(ui, repo, *map(urllib.unquote_plus, pats), **opts)
|
||||
|
||||
@command('rhmanifest',
|
||||
[('r', 'rev', '', 'show the specified revision')],
|
||||
'hg rhmanifest [-r REV] [PATH]')
|
||||
def rhmanifest(ui, repo, path='', **opts):
|
||||
"""output the sub-manifest of the specified directory"""
|
||||
ui.write('<?xml version="1.0"?>\n')
|
||||
ui.write('<rhmanifest>\n')
|
||||
ui.write('<repository root="%s">\n' % _u(repo.root))
|
||||
try:
|
||||
_manifest(ui, repo, urllib.unquote_plus(path), urllib.unquote_plus(opts.get('rev')))
|
||||
finally:
|
||||
ui.write('</repository>\n')
|
||||
ui.write('</rhmanifest>\n')
|
||||
|
||||
@command('rhsummary',[], 'hg rhsummary')
|
||||
def rhsummary(ui, repo, **opts):
|
||||
"""output the summary of the repository"""
|
||||
ui.write('<?xml version="1.0"?>\n')
|
||||
ui.write('<rhsummary>\n')
|
||||
ui.write('<repository root="%s">\n' % _u(repo.root))
|
||||
try:
|
||||
_tip(ui, repo)
|
||||
_tags(ui, repo)
|
||||
_branches(ui, repo)
|
||||
# TODO: bookmarks in core (Mercurial>=1.8)
|
||||
finally:
|
||||
ui.write('</repository>\n')
|
||||
ui.write('</rhsummary>\n')
|
||||
|
345
lib/redmine/scm/adapters/mercurial_adapter.rb
Normal file
345
lib/redmine/scm/adapters/mercurial_adapter.rb
Normal file
|
@ -0,0 +1,345 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter'
|
||||
require 'cgi'
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class MercurialAdapter < AbstractAdapter
|
||||
|
||||
# Mercurial executable name
|
||||
HG_BIN = Redmine::Configuration['scm_mercurial_command'] || "hg"
|
||||
HELPERS_DIR = File.dirname(__FILE__) + "/mercurial"
|
||||
HG_HELPER_EXT = "#{HELPERS_DIR}/redminehelper.py"
|
||||
TEMPLATE_NAME = "hg-template"
|
||||
TEMPLATE_EXTENSION = "tmpl"
|
||||
|
||||
# raised if hg command exited with error, e.g. unknown revision.
|
||||
class HgCommandAborted < CommandFailed; end
|
||||
# raised if bad command argument detected before executing hg.
|
||||
class HgCommandArgumentError < CommandFailed; end
|
||||
|
||||
class << self
|
||||
def client_command
|
||||
@@bin ||= HG_BIN
|
||||
end
|
||||
|
||||
def sq_bin
|
||||
@@sq_bin ||= shell_quote_command
|
||||
end
|
||||
|
||||
def client_version
|
||||
@@client_version ||= (hgversion || [])
|
||||
end
|
||||
|
||||
def client_available
|
||||
client_version_above?([1, 2])
|
||||
end
|
||||
|
||||
def hgversion
|
||||
# The hg version is expressed either as a
|
||||
# release number (eg 0.9.5 or 1.0) or as a revision
|
||||
# id composed of 12 hexa characters.
|
||||
theversion = hgversion_from_command_line.dup.force_encoding('ASCII-8BIT')
|
||||
if m = theversion.match(%r{\A(.*?)((\d+\.)+\d+)})
|
||||
m[2].scan(%r{\d+}).collect(&:to_i)
|
||||
end
|
||||
end
|
||||
|
||||
def hgversion_from_command_line
|
||||
shellout("#{sq_bin} --version") { |io| io.read }.to_s
|
||||
end
|
||||
|
||||
def template_path
|
||||
@@template_path ||= template_path_for(client_version)
|
||||
end
|
||||
|
||||
def template_path_for(version)
|
||||
"#{HELPERS_DIR}/#{TEMPLATE_NAME}-1.0.#{TEMPLATE_EXTENSION}"
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(url, root_url=nil, login=nil, password=nil, path_encoding=nil)
|
||||
super
|
||||
@path_encoding = path_encoding.blank? ? 'UTF-8' : path_encoding
|
||||
end
|
||||
|
||||
def path_encoding
|
||||
@path_encoding
|
||||
end
|
||||
|
||||
def info
|
||||
tip = summary['repository']['tip']
|
||||
Info.new(:root_url => CGI.unescape(summary['repository']['root']),
|
||||
:lastrev => Revision.new(:revision => tip['revision'],
|
||||
:scmid => tip['node']))
|
||||
# rescue HgCommandAborted
|
||||
rescue Exception => e
|
||||
logger.error "hg: error during getting info: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def tags
|
||||
as_ary(summary['repository']['tag']).map { |e| e['name'] }
|
||||
end
|
||||
|
||||
# Returns map of {'tag' => 'nodeid', ...}
|
||||
def tagmap
|
||||
alist = as_ary(summary['repository']['tag']).map do |e|
|
||||
e.values_at('name', 'node')
|
||||
end
|
||||
Hash[*alist.flatten]
|
||||
end
|
||||
|
||||
def branches
|
||||
brs = []
|
||||
as_ary(summary['repository']['branch']).each do |e|
|
||||
br = Branch.new(e['name'])
|
||||
br.revision = e['revision']
|
||||
br.scmid = e['node']
|
||||
brs << br
|
||||
end
|
||||
brs
|
||||
end
|
||||
|
||||
# Returns map of {'branch' => 'nodeid', ...}
|
||||
def branchmap
|
||||
alist = as_ary(summary['repository']['branch']).map do |e|
|
||||
e.values_at('name', 'node')
|
||||
end
|
||||
Hash[*alist.flatten]
|
||||
end
|
||||
|
||||
def summary
|
||||
return @summary if @summary
|
||||
hg 'rhsummary' do |io|
|
||||
output = io.read.force_encoding('UTF-8')
|
||||
begin
|
||||
@summary = parse_xml(output)['rhsummary']
|
||||
rescue
|
||||
end
|
||||
end
|
||||
end
|
||||
private :summary
|
||||
|
||||
def entries(path=nil, identifier=nil, options={})
|
||||
p1 = scm_iconv(@path_encoding, 'UTF-8', path)
|
||||
manifest = hg('rhmanifest', "-r#{CGI.escape(hgrev(identifier))}",
|
||||
'--', CGI.escape(without_leading_slash(p1.to_s))) do |io|
|
||||
output = io.read.force_encoding('UTF-8')
|
||||
begin
|
||||
parse_xml(output)['rhmanifest']['repository']['manifest']
|
||||
rescue
|
||||
end
|
||||
end
|
||||
path_prefix = path.blank? ? '' : with_trailling_slash(path)
|
||||
|
||||
entries = Entries.new
|
||||
as_ary(manifest['dir']).each do |e|
|
||||
n = scm_iconv('UTF-8', @path_encoding, CGI.unescape(e['name']))
|
||||
p = "#{path_prefix}#{n}"
|
||||
entries << Entry.new(:name => n, :path => p, :kind => 'dir')
|
||||
end
|
||||
|
||||
as_ary(manifest['file']).each do |e|
|
||||
n = scm_iconv('UTF-8', @path_encoding, CGI.unescape(e['name']))
|
||||
p = "#{path_prefix}#{n}"
|
||||
lr = Revision.new(:revision => e['revision'], :scmid => e['node'],
|
||||
:identifier => e['node'],
|
||||
:time => Time.at(e['time'].to_i))
|
||||
entries << Entry.new(:name => n, :path => p, :kind => 'file',
|
||||
:size => e['size'].to_i, :lastrev => lr)
|
||||
end
|
||||
|
||||
entries
|
||||
rescue HgCommandAborted
|
||||
nil # means not found
|
||||
end
|
||||
|
||||
def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={})
|
||||
revs = Revisions.new
|
||||
each_revision(path, identifier_from, identifier_to, options) { |e| revs << e }
|
||||
revs
|
||||
end
|
||||
|
||||
# Iterates the revisions by using a template file that
|
||||
# makes Mercurial produce a xml output.
|
||||
def each_revision(path=nil, identifier_from=nil, identifier_to=nil, options={})
|
||||
hg_args = ['log', '--debug', '-C', "--style=#{self.class.template_path}"]
|
||||
hg_args << "-r#{hgrev(identifier_from)}:#{hgrev(identifier_to)}"
|
||||
hg_args << "--limit=#{options[:limit]}" if options[:limit]
|
||||
hg_args << '--' << hgtarget(path) unless path.blank?
|
||||
log = hg(*hg_args) do |io|
|
||||
output = io.read.force_encoding('UTF-8')
|
||||
begin
|
||||
# Mercurial < 1.5 does not support footer template for '</log>'
|
||||
parse_xml("#{output}</log>")['log']
|
||||
rescue
|
||||
end
|
||||
end
|
||||
as_ary(log['logentry']).each do |le|
|
||||
cpalist = as_ary(le['paths']['path-copied']).map do |e|
|
||||
[e['__content__'], e['copyfrom-path']].map do |s|
|
||||
scm_iconv('UTF-8', @path_encoding, CGI.unescape(s))
|
||||
end
|
||||
end
|
||||
cpmap = Hash[*cpalist.flatten]
|
||||
paths = as_ary(le['paths']['path']).map do |e|
|
||||
p = scm_iconv('UTF-8', @path_encoding, CGI.unescape(e['__content__']) )
|
||||
{:action => e['action'],
|
||||
:path => with_leading_slash(p),
|
||||
:from_path => (cpmap.member?(p) ? with_leading_slash(cpmap[p]) : nil),
|
||||
:from_revision => (cpmap.member?(p) ? le['node'] : nil)}
|
||||
end.sort { |a, b| a[:path] <=> b[:path] }
|
||||
parents_ary = []
|
||||
as_ary(le['parents']['parent']).map do |par|
|
||||
parents_ary << par['__content__'] if par['__content__'] != "0000000000000000000000000000000000000000"
|
||||
end
|
||||
yield Revision.new(:revision => le['revision'],
|
||||
:scmid => le['node'],
|
||||
:author => (le['author']['__content__'] rescue ''),
|
||||
:time => Time.parse(le['date']['__content__']),
|
||||
:message => le['msg']['__content__'],
|
||||
:paths => paths,
|
||||
:parents => parents_ary)
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
# Returns list of nodes in the specified branch
|
||||
def nodes_in_branch(branch, options={})
|
||||
hg_args = ['rhlog', '--template={node}\n', "--rhbranch=#{CGI.escape(branch)}"]
|
||||
hg_args << "--from=#{CGI.escape(branch)}"
|
||||
hg_args << '--to=0'
|
||||
hg_args << "--limit=#{options[:limit]}" if options[:limit]
|
||||
hg(*hg_args) { |io| io.readlines.map { |e| e.chomp } }
|
||||
end
|
||||
|
||||
def diff(path, identifier_from, identifier_to=nil)
|
||||
hg_args = %w|rhdiff|
|
||||
if identifier_to
|
||||
hg_args << "-r#{hgrev(identifier_to)}" << "-r#{hgrev(identifier_from)}"
|
||||
else
|
||||
hg_args << "-c#{hgrev(identifier_from)}"
|
||||
end
|
||||
unless path.blank?
|
||||
p = scm_iconv(@path_encoding, 'UTF-8', path)
|
||||
hg_args << '--' << CGI.escape(hgtarget(p))
|
||||
end
|
||||
diff = []
|
||||
hg *hg_args do |io|
|
||||
io.each_line do |line|
|
||||
diff << line
|
||||
end
|
||||
end
|
||||
diff
|
||||
rescue HgCommandAborted
|
||||
nil # means not found
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
p = CGI.escape(scm_iconv(@path_encoding, 'UTF-8', path))
|
||||
hg 'rhcat', "-r#{CGI.escape(hgrev(identifier))}", '--', hgtarget(p) do |io|
|
||||
io.binmode
|
||||
io.read
|
||||
end
|
||||
rescue HgCommandAborted
|
||||
nil # means not found
|
||||
end
|
||||
|
||||
def annotate(path, identifier=nil)
|
||||
p = CGI.escape(scm_iconv(@path_encoding, 'UTF-8', path))
|
||||
blame = Annotate.new
|
||||
hg 'rhannotate', '-ncu', "-r#{CGI.escape(hgrev(identifier))}", '--', hgtarget(p) do |io|
|
||||
io.each_line do |line|
|
||||
line.force_encoding('ASCII-8BIT')
|
||||
next unless line =~ %r{^([^:]+)\s(\d+)\s([0-9a-f]+):\s(.*)$}
|
||||
r = Revision.new(:author => $1.strip, :revision => $2, :scmid => $3,
|
||||
:identifier => $3)
|
||||
blame.add_line($4.rstrip, r)
|
||||
end
|
||||
end
|
||||
blame
|
||||
rescue HgCommandAborted
|
||||
# means not found or cannot be annotated
|
||||
Annotate.new
|
||||
end
|
||||
|
||||
class Revision < Redmine::Scm::Adapters::Revision
|
||||
# Returns the readable identifier
|
||||
def format_identifier
|
||||
"#{revision}:#{scmid}"
|
||||
end
|
||||
end
|
||||
|
||||
# command options which may be processed earlier, by faulty parser in hg
|
||||
HG_EARLY_BOOL_ARG = /^--(debugger|profile|traceback)$/
|
||||
HG_EARLY_LIST_ARG = /^(--(config|cwd|repo(sitory)?)\b|-R)/
|
||||
private_constant :HG_EARLY_BOOL_ARG, :HG_EARLY_LIST_ARG
|
||||
|
||||
# Runs 'hg' command with the given args
|
||||
def hg(*args, &block)
|
||||
# as of hg 4.4.1, early parsing of bool options is not terminated at '--'
|
||||
if args.any? { |s| s =~ HG_EARLY_BOOL_ARG }
|
||||
raise HgCommandArgumentError, "malicious command argument detected"
|
||||
end
|
||||
if args.take_while { |s| s != '--' }.any? { |s| s =~ HG_EARLY_LIST_ARG }
|
||||
raise HgCommandArgumentError, "malicious command argument detected"
|
||||
end
|
||||
|
||||
repo_path = root_url || url
|
||||
full_args = ["-R#{repo_path}", '--encoding=utf-8']
|
||||
# don't use "--config=<value>" form for compatibility with ancient Mercurial
|
||||
full_args << '--config' << "extensions.redminehelper=#{HG_HELPER_EXT}"
|
||||
full_args << '--config' << 'diff.git=false'
|
||||
full_args += args
|
||||
ret = shellout(
|
||||
self.class.sq_bin + ' ' + full_args.map { |e| shell_quote e.to_s }.join(' '),
|
||||
&block
|
||||
)
|
||||
if $? && $?.exitstatus != 0
|
||||
raise HgCommandAborted, "hg exited with non-zero status: #{$?.exitstatus}"
|
||||
end
|
||||
ret
|
||||
end
|
||||
private :hg
|
||||
|
||||
# Returns correct revision identifier
|
||||
def hgrev(identifier, sq=false)
|
||||
rev = identifier.blank? ? 'tip' : identifier.to_s
|
||||
rev = shell_quote(rev) if sq
|
||||
rev
|
||||
end
|
||||
private :hgrev
|
||||
|
||||
def hgtarget(path)
|
||||
path ||= ''
|
||||
root_url + '/' + without_leading_slash(path)
|
||||
end
|
||||
private :hgtarget
|
||||
|
||||
def as_ary(o)
|
||||
return [] unless o
|
||||
o.is_a?(Array) ? o : Array[o]
|
||||
end
|
||||
private :as_ary
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
276
lib/redmine/scm/adapters/subversion_adapter.rb
Normal file
276
lib/redmine/scm/adapters/subversion_adapter.rb
Normal file
|
@ -0,0 +1,276 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/abstract_adapter'
|
||||
require 'uri'
|
||||
|
||||
module Redmine
|
||||
module Scm
|
||||
module Adapters
|
||||
class SubversionAdapter < AbstractAdapter
|
||||
|
||||
# SVN executable name
|
||||
SVN_BIN = Redmine::Configuration['scm_subversion_command'] || "svn"
|
||||
|
||||
class << self
|
||||
def client_command
|
||||
@@bin ||= SVN_BIN
|
||||
end
|
||||
|
||||
def sq_bin
|
||||
@@sq_bin ||= shell_quote_command
|
||||
end
|
||||
|
||||
def client_version
|
||||
@@client_version ||= (svn_binary_version || [])
|
||||
end
|
||||
|
||||
def client_available
|
||||
# --xml options are introduced in 1.3.
|
||||
# http://subversion.apache.org/docs/release-notes/1.3.html
|
||||
client_version_above?([1, 3])
|
||||
end
|
||||
|
||||
def svn_binary_version
|
||||
scm_version = scm_version_from_command_line.dup.force_encoding('ASCII-8BIT')
|
||||
if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)})
|
||||
m[2].scan(%r{\d+}).collect(&:to_i)
|
||||
end
|
||||
end
|
||||
|
||||
def scm_version_from_command_line
|
||||
shellout("#{sq_bin} --version") { |io| io.read }.to_s
|
||||
end
|
||||
end
|
||||
|
||||
# Get info about the svn repository
|
||||
def info
|
||||
cmd = "#{self.class.sq_bin} info --xml #{target}"
|
||||
cmd << credentials_string
|
||||
info = nil
|
||||
shellout(cmd) do |io|
|
||||
output = io.read.force_encoding('UTF-8')
|
||||
begin
|
||||
doc = parse_xml(output)
|
||||
# root_url = doc.elements["info/entry/repository/root"].text
|
||||
info = Info.new({:root_url => doc['info']['entry']['repository']['root']['__content__'],
|
||||
:lastrev => Revision.new({
|
||||
:identifier => doc['info']['entry']['commit']['revision'],
|
||||
:time => Time.parse(doc['info']['entry']['commit']['date']['__content__']).localtime,
|
||||
:author => (doc['info']['entry']['commit']['author'] ? doc['info']['entry']['commit']['author']['__content__'] : "")
|
||||
})
|
||||
})
|
||||
rescue
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
info
|
||||
rescue CommandFailed
|
||||
return nil
|
||||
end
|
||||
|
||||
# Returns an Entries collection
|
||||
# or nil if the given path doesn't exist in the repository
|
||||
def entries(path=nil, identifier=nil, options={})
|
||||
path ||= ''
|
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : "HEAD"
|
||||
entries = Entries.new
|
||||
cmd = "#{self.class.sq_bin} list --xml #{target(path)}@#{identifier}"
|
||||
cmd << credentials_string
|
||||
shellout(cmd) do |io|
|
||||
output = io.read.force_encoding('UTF-8')
|
||||
begin
|
||||
doc = parse_xml(output)
|
||||
each_xml_element(doc['lists']['list'], 'entry') do |entry|
|
||||
commit = entry['commit']
|
||||
commit_date = commit['date']
|
||||
# Skip directory if there is no commit date (usually that
|
||||
# means that we don't have read access to it)
|
||||
next if entry['kind'] == 'dir' && commit_date.nil?
|
||||
name = entry['name']['__content__']
|
||||
entries << Entry.new({:name => URI.unescape(name),
|
||||
:path => ((path.empty? ? "" : "#{path}/") + name),
|
||||
:kind => entry['kind'],
|
||||
:size => ((s = entry['size']) ? s['__content__'].to_i : nil),
|
||||
:lastrev => Revision.new({
|
||||
:identifier => commit['revision'],
|
||||
:time => Time.parse(commit_date['__content__'].to_s).localtime,
|
||||
:author => ((a = commit['author']) ? a['__content__'] : nil)
|
||||
})
|
||||
})
|
||||
end
|
||||
rescue Exception => e
|
||||
logger.error("Error parsing svn output: #{e.message}")
|
||||
logger.error("Output was:\n #{output}")
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
logger.debug("Found #{entries.size} entries in the repository for #{target(path)}") if logger && logger.debug?
|
||||
entries.sort_by_name
|
||||
end
|
||||
|
||||
def properties(path, identifier=nil)
|
||||
# proplist xml output supported in svn 1.5.0 and higher
|
||||
return nil unless self.class.client_version_above?([1, 5, 0])
|
||||
|
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : "HEAD"
|
||||
cmd = "#{self.class.sq_bin} proplist --verbose --xml #{target(path)}@#{identifier}"
|
||||
cmd << credentials_string
|
||||
properties = {}
|
||||
shellout(cmd) do |io|
|
||||
output = io.read.force_encoding('UTF-8')
|
||||
begin
|
||||
doc = parse_xml(output)
|
||||
each_xml_element(doc['properties']['target'], 'property') do |property|
|
||||
properties[ property['name'] ] = property['__content__'].to_s
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
properties
|
||||
end
|
||||
|
||||
def revisions(path=nil, identifier_from=nil, identifier_to=nil, options={})
|
||||
path ||= ''
|
||||
identifier_from = (identifier_from && identifier_from.to_i > 0) ? identifier_from.to_i : "HEAD"
|
||||
identifier_to = (identifier_to && identifier_to.to_i > 0) ? identifier_to.to_i : 1
|
||||
revisions = Revisions.new
|
||||
cmd = "#{self.class.sq_bin} log --xml -r #{identifier_from}:#{identifier_to}"
|
||||
cmd << credentials_string
|
||||
cmd << " --verbose " if options[:with_paths]
|
||||
cmd << " --limit #{options[:limit].to_i}" if options[:limit]
|
||||
cmd << ' ' + target(path)
|
||||
shellout(cmd) do |io|
|
||||
output = io.read.force_encoding('UTF-8')
|
||||
begin
|
||||
doc = parse_xml(output)
|
||||
each_xml_element(doc['log'], 'logentry') do |logentry|
|
||||
paths = []
|
||||
each_xml_element(logentry['paths'], 'path') do |path|
|
||||
paths << {:action => path['action'],
|
||||
:path => path['__content__'],
|
||||
:from_path => path['copyfrom-path'],
|
||||
:from_revision => path['copyfrom-rev']
|
||||
}
|
||||
end if logentry['paths'] && logentry['paths']['path']
|
||||
paths.sort! { |x,y| x[:path] <=> y[:path] }
|
||||
|
||||
revisions << Revision.new({:identifier => logentry['revision'],
|
||||
:author => (logentry['author'] ? logentry['author']['__content__'] : ""),
|
||||
:time => Time.parse(logentry['date']['__content__'].to_s).localtime,
|
||||
:message => logentry['msg']['__content__'],
|
||||
:paths => paths
|
||||
})
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
revisions
|
||||
end
|
||||
|
||||
def diff(path, identifier_from, identifier_to=nil)
|
||||
path ||= ''
|
||||
identifier_from = (identifier_from and identifier_from.to_i > 0) ? identifier_from.to_i : ''
|
||||
|
||||
identifier_to = (identifier_to and identifier_to.to_i > 0) ? identifier_to.to_i : (identifier_from.to_i - 1)
|
||||
|
||||
cmd = "#{self.class.sq_bin} diff -r "
|
||||
cmd << "#{identifier_to}:"
|
||||
cmd << "#{identifier_from}"
|
||||
cmd << " #{target(path)}@#{identifier_from}"
|
||||
cmd << credentials_string
|
||||
diff = []
|
||||
shellout(cmd) do |io|
|
||||
io.each_line do |line|
|
||||
diff << line
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
diff
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : "HEAD"
|
||||
cmd = "#{self.class.sq_bin} cat #{target(path)}@#{identifier}"
|
||||
cmd << credentials_string
|
||||
cat = nil
|
||||
shellout(cmd) do |io|
|
||||
io.binmode
|
||||
cat = io.read
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
cat
|
||||
end
|
||||
|
||||
def annotate(path, identifier=nil)
|
||||
identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : "HEAD"
|
||||
cmd = "#{self.class.sq_bin} blame #{target(path)}@#{identifier}"
|
||||
cmd << credentials_string
|
||||
blame = Annotate.new
|
||||
shellout(cmd) do |io|
|
||||
io.each_line do |line|
|
||||
next unless line =~ %r{^\s*(\d+)\s*(\S+)\s(.*)$}
|
||||
rev = $1
|
||||
blame.add_line($3.rstrip,
|
||||
Revision.new(
|
||||
:identifier => rev,
|
||||
:revision => rev,
|
||||
:author => $2.strip
|
||||
))
|
||||
end
|
||||
end
|
||||
return nil if $? && $?.exitstatus != 0
|
||||
blame
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def credentials_string
|
||||
str = ''
|
||||
str << " --username #{shell_quote(@login)}" unless @login.blank?
|
||||
str << " --password #{shell_quote(@password)}" unless @login.blank? || @password.blank?
|
||||
str << " --no-auth-cache --non-interactive"
|
||||
str
|
||||
end
|
||||
|
||||
# Helper that iterates over the child elements of a xml node
|
||||
# MiniXml returns a hash when a single child is found
|
||||
# or an array of hashes for multiple children
|
||||
def each_xml_element(node, name)
|
||||
if node && node[name]
|
||||
if node[name].is_a?(Hash)
|
||||
yield node[name]
|
||||
else
|
||||
node[name].each do |element|
|
||||
yield element
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def target(path = '')
|
||||
base = path.match(/^\//) ? root_url : url
|
||||
uri = "#{base}/#{path}"
|
||||
uri = URI.escape(URI.escape(uri), '[]')
|
||||
shell_quote(uri.gsub(/[?<>\*]/, ''))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
23
lib/redmine/scm/base.rb
Normal file
23
lib/redmine/scm/base.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
module Redmine
|
||||
module Scm
|
||||
class Base
|
||||
class << self
|
||||
|
||||
def all
|
||||
@scms || []
|
||||
end
|
||||
|
||||
# Add a new SCM adapter and repository
|
||||
def add(scm_name)
|
||||
@scms ||= []
|
||||
@scms << scm_name
|
||||
end
|
||||
|
||||
# Remove a SCM adapter from Redmine's list of supported scms
|
||||
def delete(scm_name)
|
||||
@scms.delete(scm_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
173
lib/redmine/search.rb
Normal file
173
lib/redmine/search.rb
Normal file
|
@ -0,0 +1,173 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Search
|
||||
|
||||
mattr_accessor :available_search_types
|
||||
@@available_search_types = []
|
||||
|
||||
class << self
|
||||
def map(&block)
|
||||
yield self
|
||||
end
|
||||
|
||||
# Registers a search provider
|
||||
def register(search_type, options={})
|
||||
search_type = search_type.to_s
|
||||
@@available_search_types << search_type unless @@available_search_types.include?(search_type)
|
||||
end
|
||||
|
||||
# Returns the cache store for search results
|
||||
# Can be configured with config.redmine_search_cache_store= in config/application.rb
|
||||
def cache_store
|
||||
@@cache_store ||= begin
|
||||
# if config.search_cache_store was not previously set, a no method error would be raised
|
||||
config = Rails.application.config.redmine_search_cache_store rescue :memory_store
|
||||
if config
|
||||
ActiveSupport::Cache.lookup_store config
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Fetcher
|
||||
attr_reader :tokens
|
||||
|
||||
def initialize(question, user, scope, projects, options={})
|
||||
@user = user
|
||||
@question = question.strip
|
||||
@scope = scope
|
||||
@projects = projects
|
||||
@cache = options.delete(:cache)
|
||||
@options = options
|
||||
|
||||
# extract tokens from the question
|
||||
# eg. hello "bye bye" => ["hello", "bye bye"]
|
||||
@tokens = @question.scan(%r{((\s|^)"[^"]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
|
||||
# tokens must be at least 2 characters long
|
||||
@tokens = @tokens.uniq.select {|w| w.length > 1 }
|
||||
# no more than 5 tokens to search for
|
||||
@tokens.slice! 5..-1
|
||||
end
|
||||
|
||||
# Returns the total result count
|
||||
def result_count
|
||||
result_ids.size
|
||||
end
|
||||
|
||||
# Returns the result count by type
|
||||
def result_count_by_type
|
||||
ret = Hash.new {|h,k| h[k] = 0}
|
||||
result_ids.group_by(&:first).each do |scope, ids|
|
||||
ret[scope] += ids.size
|
||||
end
|
||||
ret
|
||||
end
|
||||
|
||||
# Returns the results for the given offset and limit
|
||||
def results(offset, limit)
|
||||
result_ids_to_load = result_ids[offset, limit] || []
|
||||
|
||||
results_by_scope = Hash.new {|h,k| h[k] = []}
|
||||
result_ids_to_load.group_by(&:first).each do |scope, scope_and_ids|
|
||||
klass = scope.singularize.camelcase.constantize
|
||||
results_by_scope[scope] += klass.search_results_from_ids(scope_and_ids.map(&:last))
|
||||
end
|
||||
|
||||
result_ids_to_load.map do |scope, id|
|
||||
results_by_scope[scope].detect {|record| record.id == id}
|
||||
end.compact
|
||||
end
|
||||
|
||||
# Returns the results ids, sorted by rank
|
||||
def result_ids
|
||||
@ranks_and_ids ||= load_result_ids_from_cache
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def project_ids
|
||||
Array.wrap(@projects).map(&:id)
|
||||
end
|
||||
|
||||
def load_result_ids_from_cache
|
||||
if Redmine::Search.cache_store
|
||||
cache_key = ActiveSupport::Cache.expand_cache_key(
|
||||
[@question, @user.id, @scope.sort, @options, project_ids.sort]
|
||||
)
|
||||
|
||||
Redmine::Search.cache_store.fetch(cache_key, :force => !@cache) do
|
||||
load_result_ids
|
||||
end
|
||||
else
|
||||
load_result_ids
|
||||
end
|
||||
end
|
||||
|
||||
def load_result_ids
|
||||
ret = []
|
||||
# get all the results ranks and ids
|
||||
@scope.each do |scope|
|
||||
klass = scope.singularize.camelcase.constantize
|
||||
ranks_and_ids_in_scope = klass.search_result_ranks_and_ids(@tokens, User.current, @projects, @options)
|
||||
ret += ranks_and_ids_in_scope.map {|rs| [scope, rs]}
|
||||
end
|
||||
# sort results, higher rank and id first
|
||||
ret.sort! {|a,b| b.last <=> a.last}
|
||||
# only keep ids now that results are sorted
|
||||
ret.map! {|scope, r| [scope, r.last]}
|
||||
ret
|
||||
end
|
||||
end
|
||||
|
||||
module Controller
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
@@default_search_scopes = Hash.new {|hash, key| hash[key] = {:default => nil, :actions => {}}}
|
||||
mattr_accessor :default_search_scopes
|
||||
|
||||
# Set the default search scope for a controller or specific actions
|
||||
# Examples:
|
||||
# * search_scope :issues # => sets the search scope to :issues for the whole controller
|
||||
# * search_scope :issues, :only => :index
|
||||
# * search_scope :issues, :only => [:index, :show]
|
||||
def default_search_scope(id, options = {})
|
||||
if actions = options[:only]
|
||||
actions = [] << actions unless actions.is_a?(Array)
|
||||
actions.each {|a| default_search_scopes[controller_name.to_sym][:actions][a.to_sym] = id.to_s}
|
||||
else
|
||||
default_search_scopes[controller_name.to_sym][:default] = id.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def default_search_scopes
|
||||
self.class.default_search_scopes
|
||||
end
|
||||
|
||||
# Returns the default search scope according to the current action
|
||||
def default_search_scope
|
||||
@default_search_scope ||= default_search_scopes[controller_name.to_sym][:actions][action_name.to_sym] ||
|
||||
default_search_scopes[controller_name.to_sym][:default]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
104
lib/redmine/sort_criteria.rb
Normal file
104
lib/redmine/sort_criteria.rb
Normal file
|
@ -0,0 +1,104 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
class SortCriteria < Array
|
||||
def initialize(arg=nil)
|
||||
super()
|
||||
if arg.is_a?(Array)
|
||||
replace arg
|
||||
elsif arg.is_a?(String)
|
||||
replace arg.split(',').collect {|s| s.split(':')[0..1]}
|
||||
elsif arg.respond_to?(:values)
|
||||
replace arg.values
|
||||
elsif arg
|
||||
raise ArgumentError.new("SortCriteria#new takes an Array, String or Hash, not a #{arg.class.name}.")
|
||||
end
|
||||
normalize!
|
||||
end
|
||||
|
||||
def to_param
|
||||
self.collect {|k,o| k + (o == 'desc' ? ':desc' : '')}.join(',')
|
||||
end
|
||||
|
||||
def to_a
|
||||
Array.new(self)
|
||||
end
|
||||
|
||||
def add!(key, asc)
|
||||
key = key.to_s
|
||||
delete_if {|k,o| k == key}
|
||||
prepend([key, asc])
|
||||
normalize!
|
||||
end
|
||||
|
||||
def add(*args)
|
||||
self.class.new(self).add!(*args)
|
||||
end
|
||||
|
||||
def first_key
|
||||
first.try(:first)
|
||||
end
|
||||
|
||||
def first_asc?
|
||||
first.try(:last) == 'asc'
|
||||
end
|
||||
|
||||
def key_at(arg)
|
||||
self[arg].try(:first)
|
||||
end
|
||||
|
||||
def order_at(arg)
|
||||
self[arg].try(:last)
|
||||
end
|
||||
|
||||
def order_for(key)
|
||||
detect {|k, order| key.to_s == k}.try(:last)
|
||||
end
|
||||
|
||||
def sort_clause(sortable_columns)
|
||||
if sortable_columns.is_a?(Array)
|
||||
sortable_columns = sortable_columns.inject({}) {|h,k| h[k]=k; h}
|
||||
end
|
||||
|
||||
sql = self.collect do |k,o|
|
||||
if s = sortable_columns[k]
|
||||
s = [s] unless s.is_a?(Array)
|
||||
s.collect {|c| append_order(c, o)}
|
||||
end
|
||||
end.flatten.compact
|
||||
sql.blank? ? nil : sql
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize!
|
||||
self.collect! {|s| s = Array(s); [s.first, (s.last == false || s.last.to_s == 'desc') ? 'desc' : 'asc']}
|
||||
self.slice!(3)
|
||||
self
|
||||
end
|
||||
|
||||
# Appends ASC/DESC to the sort criterion unless it has a fixed order
|
||||
def append_order(criterion, order)
|
||||
if criterion =~ / (asc|desc)$/i
|
||||
criterion
|
||||
else
|
||||
"#{criterion} #{order.to_s.upcase}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
47
lib/redmine/subclass_factory.rb
Normal file
47
lib/redmine/subclass_factory.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module SubclassFactory
|
||||
def self.included(base)
|
||||
base.extend ClassMethods
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def get_subclass(class_name)
|
||||
klass = nil
|
||||
begin
|
||||
klass = class_name.to_s.classify.constantize
|
||||
rescue
|
||||
# invalid class name
|
||||
end
|
||||
unless subclasses.include? klass
|
||||
klass = nil
|
||||
end
|
||||
klass
|
||||
end
|
||||
|
||||
# Returns an instance of the given subclass name
|
||||
def new_subclass_instance(class_name, *args)
|
||||
klass = get_subclass(class_name)
|
||||
if klass
|
||||
klass.new(*args)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
224
lib/redmine/sudo_mode.rb
Normal file
224
lib/redmine/sudo_mode.rb
Normal file
|
@ -0,0 +1,224 @@
|
|||
require 'active_support/core_ext/object/to_query'
|
||||
require 'rack/utils'
|
||||
|
||||
module Redmine
|
||||
module SudoMode
|
||||
|
||||
class SudoRequired < StandardError
|
||||
end
|
||||
|
||||
|
||||
class Form
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_accessor :password, :original_fields
|
||||
validate :check_password
|
||||
|
||||
def initialize(password = nil)
|
||||
self.password = password
|
||||
end
|
||||
|
||||
def check_password
|
||||
unless password.present? && User.current.check_password?(password)
|
||||
errors[:password] << :invalid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
module Helper
|
||||
# Represents params data from hash as hidden fields
|
||||
#
|
||||
# taken from https://github.com/brianhempel/hash_to_hidden_fields
|
||||
def hash_to_hidden_fields(hash)
|
||||
cleaned_hash = hash.reject { |k, v| v.nil? }
|
||||
pairs = cleaned_hash.to_query.split(Rack::Utils::DEFAULT_SEP)
|
||||
tags = pairs.map do |pair|
|
||||
key, value = pair.split('=', 2).map { |str| Rack::Utils.unescape(str) }
|
||||
hidden_field_tag(key, value)
|
||||
end
|
||||
tags.join("\n").html_safe
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
module Controller
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
around_action :sudo_mode
|
||||
end
|
||||
|
||||
# Sudo mode Around Filter
|
||||
#
|
||||
# Checks the 'last used' timestamp from session and sets the
|
||||
# SudoMode::active? flag accordingly.
|
||||
#
|
||||
# After the request refreshes the timestamp if sudo mode was used during
|
||||
# this request.
|
||||
def sudo_mode
|
||||
if sudo_timestamp_valid?
|
||||
SudoMode.active!
|
||||
end
|
||||
yield
|
||||
update_sudo_timestamp! if SudoMode.was_used?
|
||||
end
|
||||
|
||||
# This renders the sudo mode form / handles sudo form submission.
|
||||
#
|
||||
# Call this method in controller actions if sudo permissions are required
|
||||
# for processing this request. This approach is good in cases where the
|
||||
# action needs to be protected in any case or where the check is simple.
|
||||
#
|
||||
# In cases where this decision depends on complex conditions in the model,
|
||||
# consider the declarative approach using the require_sudo_mode class
|
||||
# method and a corresponding declaration in the model that causes it to throw
|
||||
# a SudoRequired Error when necessary.
|
||||
#
|
||||
# All parameter names given are included as hidden fields to be resubmitted
|
||||
# along with the password.
|
||||
#
|
||||
# Returns true when processing the action should continue, false otherwise.
|
||||
# If false is returned, render has already been called for display of the
|
||||
# password form.
|
||||
#
|
||||
# if @user.mail_changed?
|
||||
# require_sudo_mode :user or return
|
||||
# end
|
||||
#
|
||||
def require_sudo_mode(*param_names)
|
||||
return true if SudoMode.active?
|
||||
|
||||
if param_names.blank?
|
||||
param_names = params.keys - %w(id action controller sudo_password _method authenticity_token utf8)
|
||||
end
|
||||
|
||||
process_sudo_form
|
||||
|
||||
if SudoMode.active?
|
||||
true
|
||||
else
|
||||
render_sudo_form param_names
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# display the sudo password form
|
||||
def render_sudo_form(param_names)
|
||||
@sudo_form ||= SudoMode::Form.new
|
||||
@sudo_form.original_fields = params.slice( *param_names )
|
||||
# a simple 'render "sudo_mode/new"' works when used directly inside an
|
||||
# action, but not when called from a before_action:
|
||||
respond_to do |format|
|
||||
format.html { render 'sudo_mode/new' }
|
||||
format.js { render 'sudo_mode/new' }
|
||||
end
|
||||
end
|
||||
|
||||
# handle sudo password form submit
|
||||
def process_sudo_form
|
||||
if params[:sudo_password]
|
||||
@sudo_form = SudoMode::Form.new(params[:sudo_password])
|
||||
if @sudo_form.valid?
|
||||
SudoMode.active!
|
||||
else
|
||||
flash.now[:error] = l(:notice_account_wrong_password)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sudo_timestamp_valid?
|
||||
session[:sudo_timestamp].to_i > SudoMode.timeout.ago.to_i
|
||||
end
|
||||
|
||||
def update_sudo_timestamp!(new_value = Time.now.to_i)
|
||||
session[:sudo_timestamp] = new_value
|
||||
end
|
||||
|
||||
# Before Filter which is used by the require_sudo_mode class method.
|
||||
class SudoRequestFilter < Struct.new(:parameters, :request_methods)
|
||||
def before(controller)
|
||||
method_matches = request_methods.blank? || request_methods.include?(controller.request.method_symbol)
|
||||
if controller.api_request?
|
||||
true
|
||||
elsif SudoMode.possible? && method_matches
|
||||
controller.require_sudo_mode( *parameters )
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
|
||||
# Handles sudo requirements for the given actions, preserving the named
|
||||
# parameters, or any parameters if you omit the :parameters option.
|
||||
#
|
||||
# Sudo enforcement by default is active for all requests to an action
|
||||
# but may be limited to a certain subset of request methods via the
|
||||
# :only option.
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# require_sudo_mode :account, only: :post
|
||||
# require_sudo_mode :update, :create, parameters: %w(role)
|
||||
# require_sudo_mode :destroy
|
||||
#
|
||||
def require_sudo_mode(*args)
|
||||
actions = args.dup
|
||||
options = actions.extract_options!
|
||||
filter = SudoRequestFilter.new Array(options[:parameters]), Array(options[:only])
|
||||
before_action filter, only: actions
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# true if the sudo mode state was queried during this request
|
||||
def self.was_used?
|
||||
!!RequestStore.store[:sudo_mode_was_used]
|
||||
end
|
||||
|
||||
# true if sudo mode is currently active.
|
||||
#
|
||||
# Calling this method also turns was_used? to true, therefore
|
||||
# it is important to only call this when sudo is actually needed, as the last
|
||||
# condition to determine whether a change can be done or not.
|
||||
#
|
||||
# If you do it wrong, timeout of the sudo mode will happen too late or not at
|
||||
# all.
|
||||
def self.active?
|
||||
if !!RequestStore.store[:sudo_mode]
|
||||
RequestStore.store[:sudo_mode_was_used] = true
|
||||
end
|
||||
end
|
||||
|
||||
def self.active!
|
||||
RequestStore.store[:sudo_mode] = true
|
||||
end
|
||||
|
||||
def self.possible?
|
||||
enabled? && User.current.logged?
|
||||
end
|
||||
|
||||
# Turn off sudo mode (never require password entry).
|
||||
def self.disable!
|
||||
RequestStore.store[:sudo_mode_disabled] = true
|
||||
end
|
||||
|
||||
# Turn sudo mode back on
|
||||
def self.enable!
|
||||
RequestStore.store[:sudo_mode_disabled] = nil
|
||||
end
|
||||
|
||||
def self.enabled?
|
||||
Redmine::Configuration['sudo_mode'] && !RequestStore.store[:sudo_mode_disabled]
|
||||
end
|
||||
|
||||
# Timespan after which sudo mode expires when unused.
|
||||
def self.timeout
|
||||
m = Redmine::Configuration['sudo_mode_timeout'].to_i
|
||||
(m > 0 ? m : 15).minutes
|
||||
end
|
||||
end
|
||||
end
|
93
lib/redmine/syntax_highlighting.rb
Normal file
93
lib/redmine/syntax_highlighting.rb
Normal file
|
@ -0,0 +1,93 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module SyntaxHighlighting
|
||||
|
||||
class << self
|
||||
attr_reader :highlighter
|
||||
|
||||
def highlighter=(name)
|
||||
if name.is_a?(Module)
|
||||
@highlighter = name
|
||||
else
|
||||
@highlighter = const_get(name)
|
||||
end
|
||||
end
|
||||
|
||||
def highlight_by_filename(text, filename)
|
||||
highlighter.highlight_by_filename(text, filename)
|
||||
rescue
|
||||
ERB::Util.h(text)
|
||||
end
|
||||
|
||||
def highlight_by_language(text, language)
|
||||
highlighter.highlight_by_language(text, language)
|
||||
rescue
|
||||
ERB::Util.h(text)
|
||||
end
|
||||
|
||||
def language_supported?(language)
|
||||
if highlighter.respond_to? :language_supported?
|
||||
highlighter.language_supported? language
|
||||
else
|
||||
true
|
||||
end
|
||||
rescue
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
module CodeRay
|
||||
require 'coderay'
|
||||
|
||||
def self.retrieve_supported_languages
|
||||
::CodeRay::Scanners.list +
|
||||
# Add CodeRay scanner aliases
|
||||
::CodeRay::Scanners.plugin_hash.keys.map(&:to_sym) -
|
||||
# Remove internal CodeRay scanners
|
||||
%w(debug default raydebug scanner).map(&:to_sym)
|
||||
end
|
||||
private_class_method :retrieve_supported_languages
|
||||
|
||||
SUPPORTED_LANGUAGES = retrieve_supported_languages
|
||||
|
||||
class << self
|
||||
# Highlights +text+ as the content of +filename+
|
||||
# Should not return line numbers nor outer pre tag
|
||||
def highlight_by_filename(text, filename)
|
||||
language = ::CodeRay::FileType[filename]
|
||||
language ? ::CodeRay.scan(text, language).html(:break_lines => true) : ERB::Util.h(text)
|
||||
end
|
||||
|
||||
# Highlights +text+ using +language+ syntax
|
||||
# Should not return outer pre tag
|
||||
def highlight_by_language(text, language)
|
||||
::CodeRay.scan(text, language).html(:wrap => :span)
|
||||
end
|
||||
|
||||
def language_supported?(language)
|
||||
SUPPORTED_LANGUAGES.include?(language.to_s.downcase.to_sym)
|
||||
rescue
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SyntaxHighlighting.highlighter = 'CodeRay'
|
||||
end
|
143
lib/redmine/themes.rb
Normal file
143
lib/redmine/themes.rb
Normal file
|
@ -0,0 +1,143 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Themes
|
||||
|
||||
# Return an array of installed themes
|
||||
def self.themes
|
||||
@@installed_themes ||= scan_themes
|
||||
end
|
||||
|
||||
# Rescan themes directory
|
||||
def self.rescan
|
||||
@@installed_themes = scan_themes
|
||||
end
|
||||
|
||||
# Return theme for given id, or nil if it's not found
|
||||
def self.theme(id, options={})
|
||||
return nil if id.blank?
|
||||
|
||||
found = themes.find {|t| t.id == id}
|
||||
if found.nil? && options[:rescan] != false
|
||||
rescan
|
||||
found = theme(id, :rescan => false)
|
||||
end
|
||||
found
|
||||
end
|
||||
|
||||
# Class used to represent a theme
|
||||
class Theme
|
||||
attr_reader :path, :name, :dir
|
||||
|
||||
def initialize(path)
|
||||
@path = path
|
||||
@dir = File.basename(path)
|
||||
@name = @dir.humanize
|
||||
@stylesheets = nil
|
||||
@javascripts = nil
|
||||
end
|
||||
|
||||
# Directory name used as the theme id
|
||||
def id; dir end
|
||||
|
||||
def ==(theme)
|
||||
theme.is_a?(Theme) && theme.dir == dir
|
||||
end
|
||||
|
||||
def <=>(theme)
|
||||
name <=> theme.name
|
||||
end
|
||||
|
||||
def stylesheets
|
||||
@stylesheets ||= assets("stylesheets", "css")
|
||||
end
|
||||
|
||||
def images
|
||||
@images ||= assets("images")
|
||||
end
|
||||
|
||||
def javascripts
|
||||
@javascripts ||= assets("javascripts", "js")
|
||||
end
|
||||
|
||||
def favicons
|
||||
@favicons ||= assets("favicon")
|
||||
end
|
||||
|
||||
def favicon
|
||||
favicons.first
|
||||
end
|
||||
|
||||
def favicon?
|
||||
favicon.present?
|
||||
end
|
||||
|
||||
def stylesheet_path(source)
|
||||
"/themes/#{dir}/stylesheets/#{source}"
|
||||
end
|
||||
|
||||
def image_path(source)
|
||||
"/themes/#{dir}/images/#{source}"
|
||||
end
|
||||
|
||||
def javascript_path(source)
|
||||
"/themes/#{dir}/javascripts/#{source}"
|
||||
end
|
||||
|
||||
def favicon_path
|
||||
"/themes/#{dir}/favicon/#{favicon}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assets(dir, ext=nil)
|
||||
if ext
|
||||
Dir.glob("#{path}/#{dir}/*.#{ext}").collect {|f| File.basename(f).gsub(/\.#{ext}$/, '')}
|
||||
else
|
||||
Dir.glob("#{path}/#{dir}/*").collect {|f| File.basename(f)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module Helper
|
||||
def current_theme
|
||||
unless instance_variable_defined?(:@current_theme)
|
||||
@current_theme = Redmine::Themes.theme(Setting.ui_theme)
|
||||
end
|
||||
@current_theme
|
||||
end
|
||||
|
||||
# Returns the header tags for the current theme
|
||||
def heads_for_theme
|
||||
if current_theme && current_theme.javascripts.include?('theme')
|
||||
javascript_include_tag current_theme.javascript_path('theme')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.scan_themes
|
||||
dirs = Dir.glob("#{Rails.public_path}/themes/*").select do |f|
|
||||
# A theme should at least override application.css
|
||||
File.directory?(f) && File.exist?("#{f}/stylesheets/application.css")
|
||||
end
|
||||
dirs.collect {|dir| Theme.new(dir)}.sort
|
||||
end
|
||||
end
|
||||
end
|
61
lib/redmine/thumbnail.rb
Normal file
61
lib/redmine/thumbnail.rb
Normal file
|
@ -0,0 +1,61 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'fileutils'
|
||||
require 'mimemagic'
|
||||
|
||||
module Redmine
|
||||
module Thumbnail
|
||||
extend Redmine::Utils::Shell
|
||||
|
||||
CONVERT_BIN = (Redmine::Configuration['imagemagick_convert_command'] || 'convert').freeze
|
||||
ALLOWED_TYPES = %w(image/bmp image/gif image/jpeg image/png)
|
||||
|
||||
# Generates a thumbnail for the source image to target
|
||||
def self.generate(source, target, size)
|
||||
return nil unless convert_available?
|
||||
unless File.exists?(target)
|
||||
# Make sure we only invoke Imagemagick if the file type is allowed
|
||||
unless File.open(source) {|f| ALLOWED_TYPES.include? MimeMagic.by_magic(f).try(:type) }
|
||||
return nil
|
||||
end
|
||||
directory = File.dirname(target)
|
||||
unless File.exists?(directory)
|
||||
FileUtils.mkdir_p directory
|
||||
end
|
||||
size_option = "#{size}x#{size}>"
|
||||
cmd = "#{shell_quote CONVERT_BIN} #{shell_quote source} -thumbnail #{shell_quote size_option} #{shell_quote target}"
|
||||
unless system(cmd)
|
||||
logger.error("Creating thumbnail failed (#{$?}):\nCommand: #{cmd}")
|
||||
return nil
|
||||
end
|
||||
end
|
||||
target
|
||||
end
|
||||
|
||||
def self.convert_available?
|
||||
return @convert_available if defined?(@convert_available)
|
||||
@convert_available = system("#{shell_quote CONVERT_BIN} -version") rescue false
|
||||
logger.warn("Imagemagick's convert binary (#{CONVERT_BIN}) not available") unless @convert_available
|
||||
@convert_available
|
||||
end
|
||||
|
||||
def self.logger
|
||||
Rails.logger
|
||||
end
|
||||
end
|
||||
end
|
284
lib/redmine/unified_diff.rb
Normal file
284
lib/redmine/unified_diff.rb
Normal file
|
@ -0,0 +1,284 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
# Class used to parse unified diffs
|
||||
class UnifiedDiff < Array
|
||||
attr_reader :diff_type, :diff_style
|
||||
|
||||
def initialize(diff, options={})
|
||||
options.assert_valid_keys(:type, :style, :max_lines)
|
||||
diff = diff.split("\n") if diff.is_a?(String)
|
||||
@diff_type = options[:type] || 'inline'
|
||||
@diff_style = options[:style]
|
||||
lines = 0
|
||||
@truncated = false
|
||||
diff_table = DiffTable.new(diff_type, diff_style)
|
||||
diff.each do |line_raw|
|
||||
line = Redmine::CodesetUtil.to_utf8_by_setting(line_raw)
|
||||
unless diff_table.add_line(line)
|
||||
self << diff_table if diff_table.length > 0
|
||||
diff_table = DiffTable.new(diff_type, diff_style)
|
||||
end
|
||||
lines += 1
|
||||
if options[:max_lines] && lines > options[:max_lines]
|
||||
@truncated = true
|
||||
break
|
||||
end
|
||||
end
|
||||
self << diff_table unless diff_table.empty?
|
||||
self
|
||||
end
|
||||
|
||||
def truncated?; @truncated; end
|
||||
end
|
||||
|
||||
# Class that represents a file diff
|
||||
class DiffTable < Array
|
||||
attr_reader :file_name
|
||||
|
||||
# Initialize with a Diff file and the type of Diff View
|
||||
# The type view must be inline or sbs (side_by_side)
|
||||
def initialize(type="inline", style=nil)
|
||||
@parsing = false
|
||||
@added = 0
|
||||
@removed = 0
|
||||
@type = type
|
||||
@style = style
|
||||
@file_name = nil
|
||||
@git_diff = false
|
||||
end
|
||||
|
||||
# Function for add a line of this Diff
|
||||
# Returns false when the diff ends
|
||||
def add_line(line)
|
||||
unless @parsing
|
||||
if line =~ /^(---|\+\+\+) (.*)$/
|
||||
self.file_name = $2
|
||||
elsif line =~ /^@@ (\+|\-)(\d+)(,\d+)? (\+|\-)(\d+)(,\d+)? @@/
|
||||
@line_num_l = $2.to_i
|
||||
@line_num_r = $5.to_i
|
||||
@parsing = true
|
||||
end
|
||||
else
|
||||
if line =~ %r{^[^\+\-\s@\\]}
|
||||
@parsing = false
|
||||
return false
|
||||
elsif line =~ /^@@ (\+|\-)(\d+)(,\d+)? (\+|\-)(\d+)(,\d+)? @@/
|
||||
@line_num_l = $2.to_i
|
||||
@line_num_r = $5.to_i
|
||||
else
|
||||
parse_line(line, @type)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
def each_line
|
||||
prev_line_left, prev_line_right = nil, nil
|
||||
each do |line|
|
||||
spacing = prev_line_left && prev_line_right && (line.nb_line_left != prev_line_left+1) && (line.nb_line_right != prev_line_right+1)
|
||||
yield spacing, line
|
||||
prev_line_left = line.nb_line_left.to_i if line.nb_line_left.to_i > 0
|
||||
prev_line_right = line.nb_line_right.to_i if line.nb_line_right.to_i > 0
|
||||
end
|
||||
end
|
||||
|
||||
def inspect
|
||||
puts '### DIFF TABLE ###'
|
||||
puts "file : #{file_name}"
|
||||
self.each do |d|
|
||||
d.inspect
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def file_name=(arg)
|
||||
both_git_diff = false
|
||||
if file_name.nil?
|
||||
@git_diff = true if arg =~ %r{^(a/|/dev/null)}
|
||||
else
|
||||
both_git_diff = (@git_diff && arg =~ %r{^(b/|/dev/null)})
|
||||
end
|
||||
if both_git_diff
|
||||
if file_name && arg == "/dev/null"
|
||||
# keep the original file name
|
||||
@file_name = file_name.sub(%r{^a/}, '')
|
||||
else
|
||||
# remove leading b/
|
||||
@file_name = arg.sub(%r{^b/}, '')
|
||||
end
|
||||
elsif @style == "Subversion"
|
||||
# removing trailing "(revision nn)"
|
||||
@file_name = arg.sub(%r{\t+\(.*\)$}, '')
|
||||
else
|
||||
@file_name = arg
|
||||
end
|
||||
end
|
||||
|
||||
def diff_for_added_line
|
||||
if @type == 'sbs' && @removed > 0 && @added < @removed
|
||||
self[-(@removed - @added)]
|
||||
else
|
||||
diff = Diff.new
|
||||
self << diff
|
||||
diff
|
||||
end
|
||||
end
|
||||
|
||||
def parse_line(line, type="inline")
|
||||
if line[0, 1] == "+"
|
||||
diff = diff_for_added_line
|
||||
diff.line_right = line[1..-1]
|
||||
diff.nb_line_right = @line_num_r
|
||||
diff.type_diff_right = 'diff_in'
|
||||
@line_num_r += 1
|
||||
@added += 1
|
||||
true
|
||||
elsif line[0, 1] == "-"
|
||||
diff = Diff.new
|
||||
diff.line_left = line[1..-1]
|
||||
diff.nb_line_left = @line_num_l
|
||||
diff.type_diff_left = 'diff_out'
|
||||
self << diff
|
||||
@line_num_l += 1
|
||||
@removed += 1
|
||||
true
|
||||
else
|
||||
write_offsets
|
||||
if line[0, 1] =~ /\s/
|
||||
diff = Diff.new
|
||||
diff.line_right = line[1..-1]
|
||||
diff.nb_line_right = @line_num_r
|
||||
diff.line_left = line[1..-1]
|
||||
diff.nb_line_left = @line_num_l
|
||||
self << diff
|
||||
@line_num_l += 1
|
||||
@line_num_r += 1
|
||||
true
|
||||
elsif line[0, 1] = "\\"
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def write_offsets
|
||||
if @added > 0 && @added == @removed
|
||||
@added.times do |i|
|
||||
line = self[-(1 + i)]
|
||||
removed = (@type == 'sbs') ? line : self[-(1 + @added + i)]
|
||||
offsets = offsets(removed.line_left, line.line_right)
|
||||
removed.offsets = line.offsets = offsets
|
||||
end
|
||||
end
|
||||
@added = 0
|
||||
@removed = 0
|
||||
end
|
||||
|
||||
def offsets(line_left, line_right)
|
||||
if line_left.present? && line_right.present? && line_left != line_right
|
||||
max = [line_left.size, line_right.size].min
|
||||
starting = 0
|
||||
while starting < max && line_left[starting] == line_right[starting]
|
||||
starting += 1
|
||||
end
|
||||
ending = -1
|
||||
while ending >= -(max - starting) && (line_left[ending] == line_right[ending])
|
||||
ending -= 1
|
||||
end
|
||||
unless starting == 0 && ending == -1
|
||||
[starting, ending]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# A line of diff
|
||||
class Diff
|
||||
attr_accessor :nb_line_left
|
||||
attr_accessor :line_left
|
||||
attr_accessor :nb_line_right
|
||||
attr_accessor :line_right
|
||||
attr_accessor :type_diff_right
|
||||
attr_accessor :type_diff_left
|
||||
attr_accessor :offsets
|
||||
|
||||
def initialize()
|
||||
self.nb_line_left = ''
|
||||
self.nb_line_right = ''
|
||||
self.line_left = ''
|
||||
self.line_right = ''
|
||||
self.type_diff_right = ''
|
||||
self.type_diff_left = ''
|
||||
end
|
||||
|
||||
def type_diff
|
||||
type_diff_right == 'diff_in' ? type_diff_right : type_diff_left
|
||||
end
|
||||
|
||||
def line
|
||||
type_diff_right == 'diff_in' ? line_right : line_left
|
||||
end
|
||||
|
||||
def html_line_left
|
||||
line_to_html(line_left, offsets)
|
||||
end
|
||||
|
||||
def html_line_right
|
||||
line_to_html(line_right, offsets)
|
||||
end
|
||||
|
||||
def html_line
|
||||
line_to_html(line, offsets)
|
||||
end
|
||||
|
||||
def inspect
|
||||
puts '### Start Line Diff ###'
|
||||
puts self.nb_line_left
|
||||
puts self.line_left
|
||||
puts self.nb_line_right
|
||||
puts self.line_right
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def line_to_html(line, offsets)
|
||||
html = line_to_html_raw(line, offsets)
|
||||
html.force_encoding('UTF-8')
|
||||
html
|
||||
end
|
||||
|
||||
def line_to_html_raw(line, offsets)
|
||||
if offsets
|
||||
s = ''
|
||||
unless offsets.first == 0
|
||||
s << CGI.escapeHTML(line[0..offsets.first-1])
|
||||
end
|
||||
s << '<span>' + CGI.escapeHTML(line[offsets.first..offsets.last]) + '</span>'
|
||||
unless offsets.last == -1
|
||||
s << CGI.escapeHTML(line[offsets.last+1..-1])
|
||||
end
|
||||
s
|
||||
else
|
||||
CGI.escapeHTML(line)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
150
lib/redmine/utils.rb
Normal file
150
lib/redmine/utils.rb
Normal file
|
@ -0,0 +1,150 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'fileutils'
|
||||
|
||||
module Redmine
|
||||
module Utils
|
||||
class << self
|
||||
# Returns the relative root url of the application
|
||||
def relative_url_root
|
||||
ActionController::Base.respond_to?('relative_url_root') ?
|
||||
ActionController::Base.relative_url_root.to_s :
|
||||
ActionController::Base.config.relative_url_root.to_s
|
||||
end
|
||||
|
||||
# Sets the relative root url of the application
|
||||
def relative_url_root=(arg)
|
||||
if ActionController::Base.respond_to?('relative_url_root=')
|
||||
ActionController::Base.relative_url_root=arg
|
||||
else
|
||||
ActionController::Base.config.relative_url_root = arg
|
||||
end
|
||||
end
|
||||
|
||||
# Generates a n bytes random hex string
|
||||
# Example:
|
||||
# random_hex(4) # => "89b8c729"
|
||||
def random_hex(n)
|
||||
SecureRandom.hex(n)
|
||||
end
|
||||
|
||||
def save_upload(upload, path)
|
||||
directory = File.dirname(path)
|
||||
unless File.exists?(directory)
|
||||
FileUtils.mkdir_p directory
|
||||
end
|
||||
File.open(path, "wb") do |f|
|
||||
if upload.respond_to?(:read)
|
||||
buffer = ""
|
||||
while (buffer = upload.read(8192))
|
||||
f.write(buffer)
|
||||
yield buffer if block_given?
|
||||
end
|
||||
else
|
||||
f.write(upload)
|
||||
yield upload if block_given?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module Shell
|
||||
|
||||
module_function
|
||||
|
||||
def shell_quote(str)
|
||||
if Redmine::Platform.mswin?
|
||||
'"' + str.gsub(/"/, '\\"') + '"'
|
||||
else
|
||||
"'" + str.gsub(/'/, "'\"'\"'") + "'"
|
||||
end
|
||||
end
|
||||
|
||||
def shell_quote_command(command)
|
||||
if Redmine::Platform.mswin? && RUBY_PLATFORM == 'java'
|
||||
command
|
||||
else
|
||||
shell_quote(command)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module DateCalculation
|
||||
# Returns the number of working days between from and to
|
||||
def working_days(from, to)
|
||||
days = (to - from).to_i
|
||||
if days > 0
|
||||
weeks = days / 7
|
||||
result = weeks * (7 - non_working_week_days.size)
|
||||
days_left = days - weeks * 7
|
||||
start_cwday = from.cwday
|
||||
days_left.times do |i|
|
||||
unless non_working_week_days.include?(((start_cwday + i - 1) % 7) + 1)
|
||||
result += 1
|
||||
end
|
||||
end
|
||||
result
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
# Adds working days to the given date
|
||||
def add_working_days(date, working_days)
|
||||
if working_days > 0
|
||||
weeks = working_days / (7 - non_working_week_days.size)
|
||||
result = weeks * 7
|
||||
days_left = working_days - weeks * (7 - non_working_week_days.size)
|
||||
cwday = date.cwday
|
||||
while days_left > 0
|
||||
cwday += 1
|
||||
unless non_working_week_days.include?(((cwday - 1) % 7) + 1)
|
||||
days_left -= 1
|
||||
end
|
||||
result += 1
|
||||
end
|
||||
next_working_date(date + result)
|
||||
else
|
||||
date
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the date of the first day on or after the given date that is a working day
|
||||
def next_working_date(date)
|
||||
cwday = date.cwday
|
||||
days = 0
|
||||
while non_working_week_days.include?(((cwday + days - 1) % 7) + 1)
|
||||
days += 1
|
||||
end
|
||||
date + days
|
||||
end
|
||||
|
||||
# Returns the index of non working week days (1=monday, 7=sunday)
|
||||
def non_working_week_days
|
||||
@non_working_week_days ||= begin
|
||||
days = Setting.non_working_week_days
|
||||
if days.is_a?(Array) && days.size < 7
|
||||
days.map(&:to_i)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
37
lib/redmine/version.rb
Normal file
37
lib/redmine/version.rb
Normal file
|
@ -0,0 +1,37 @@
|
|||
require 'rexml/document'
|
||||
|
||||
module Redmine
|
||||
module VERSION #:nodoc:
|
||||
MAJOR = 3
|
||||
MINOR = 4
|
||||
TINY = 4
|
||||
|
||||
# Branch values:
|
||||
# * official release: nil
|
||||
# * stable branch: stable
|
||||
# * trunk: devel
|
||||
BRANCH = 'stable'
|
||||
|
||||
# Retrieves the revision from the working copy
|
||||
def self.revision
|
||||
if File.directory?(File.join(Rails.root, '.svn'))
|
||||
begin
|
||||
path = Redmine::Scm::Adapters::AbstractAdapter.shell_quote(Rails.root.to_s)
|
||||
if `svn info --xml #{path}` =~ /revision="(\d+)"/
|
||||
return $1.to_i
|
||||
end
|
||||
rescue
|
||||
# Could not find the current revision
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
REVISION = self.revision
|
||||
ARRAY = [MAJOR, MINOR, TINY, BRANCH, REVISION].compact
|
||||
STRING = ARRAY.join('.')
|
||||
|
||||
def self.to_a; ARRAY end
|
||||
def self.to_s; STRING end
|
||||
end
|
||||
end
|
26
lib/redmine/views/api_template_handler.rb
Normal file
26
lib/redmine/views/api_template_handler.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Views
|
||||
class ApiTemplateHandler
|
||||
def self.call(template)
|
||||
"Redmine::Views::Builders.for(params[:format], request, response) do |api|; #{template.source}; self.output_buffer = api.output; end"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
38
lib/redmine/views/builders.rb
Normal file
38
lib/redmine/views/builders.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/views/builders/json'
|
||||
require 'redmine/views/builders/xml'
|
||||
|
||||
module Redmine
|
||||
module Views
|
||||
module Builders
|
||||
def self.for(format, request, response, &block)
|
||||
builder = case format
|
||||
when 'xml', :xml; Builders::Xml.new(request, response)
|
||||
when 'json', :json; Builders::Json.new(request, response)
|
||||
else; raise "No builder for format #{format}"
|
||||
end
|
||||
if block
|
||||
block.call(builder)
|
||||
else
|
||||
builder
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
45
lib/redmine/views/builders/json.rb
Normal file
45
lib/redmine/views/builders/json.rb
Normal file
|
@ -0,0 +1,45 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/views/builders/structure'
|
||||
|
||||
module Redmine
|
||||
module Views
|
||||
module Builders
|
||||
class Json < Structure
|
||||
attr_accessor :jsonp
|
||||
|
||||
def initialize(request, response)
|
||||
super
|
||||
callback = request.params[:callback] || request.params[:jsonp]
|
||||
if callback && Setting.jsonp_enabled?
|
||||
self.jsonp = callback.to_s.gsub(/[^a-zA-Z0-9_.]/, '')
|
||||
end
|
||||
end
|
||||
|
||||
def output
|
||||
json = @struct.first.to_json
|
||||
if jsonp.present?
|
||||
json = "#{jsonp}(#{json})"
|
||||
response.content_type = 'application/javascript'
|
||||
end
|
||||
json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
94
lib/redmine/views/builders/structure.rb
Normal file
94
lib/redmine/views/builders/structure.rb
Normal file
|
@ -0,0 +1,94 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'blankslate'
|
||||
|
||||
module Redmine
|
||||
module Views
|
||||
module Builders
|
||||
class Structure < BlankSlate
|
||||
attr_accessor :request, :response
|
||||
|
||||
def initialize(request, response)
|
||||
@struct = [{}]
|
||||
self.request = request
|
||||
self.response = response
|
||||
end
|
||||
|
||||
def array(tag, options={}, &block)
|
||||
@struct << []
|
||||
block.call(self)
|
||||
ret = @struct.pop
|
||||
@struct.last[tag] = ret
|
||||
@struct.last.merge!(options) if options
|
||||
end
|
||||
|
||||
def encode_value(value)
|
||||
if value.is_a?(Time)
|
||||
# Rails uses a global setting to format JSON times
|
||||
# Don't rely on it for the API as it could have been changed
|
||||
value.xmlschema(0)
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def method_missing(sym, *args, &block)
|
||||
if args.any?
|
||||
if args.first.is_a?(Hash)
|
||||
if @struct.last.is_a?(Array)
|
||||
@struct.last << args.first unless block
|
||||
else
|
||||
@struct.last[sym] = args.first
|
||||
end
|
||||
else
|
||||
value = encode_value(args.first)
|
||||
if @struct.last.is_a?(Array)
|
||||
if args.size == 1 && !block_given?
|
||||
@struct.last << value
|
||||
else
|
||||
@struct.last << (args.last || {}).merge(:value => value)
|
||||
end
|
||||
else
|
||||
@struct.last[sym] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if block
|
||||
@struct << (args.first.is_a?(Hash) ? args.first : {})
|
||||
block.call(self)
|
||||
ret = @struct.pop
|
||||
if @struct.last.is_a?(Array)
|
||||
@struct.last << ret
|
||||
else
|
||||
if @struct.last.has_key?(sym) && @struct.last[sym].is_a?(Hash)
|
||||
@struct.last[sym].merge! ret
|
||||
else
|
||||
@struct.last[sym] = ret
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def output
|
||||
raise "Need to implement #{self.class.name}#output"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
48
lib/redmine/views/builders/xml.rb
Normal file
48
lib/redmine/views/builders/xml.rb
Normal file
|
@ -0,0 +1,48 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'builder'
|
||||
|
||||
module Redmine
|
||||
module Views
|
||||
module Builders
|
||||
class Xml < ::Builder::XmlMarkup
|
||||
def initialize(request, response)
|
||||
super()
|
||||
instruct!
|
||||
end
|
||||
|
||||
def output
|
||||
target!
|
||||
end
|
||||
|
||||
# Overrides Builder::XmlBase#tag! to format timestamps in ISO 8601
|
||||
def tag!(sym, *args, &block)
|
||||
if args.size == 1 && args.first.is_a?(::Time)
|
||||
tag! sym, args.first.xmlschema, &block
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def array(name, options={}, &block)
|
||||
__send__ name, (options || {}).merge(:type => 'array'), &block
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
64
lib/redmine/views/labelled_form_builder.rb
Normal file
64
lib/redmine/views/labelled_form_builder.rb
Normal file
|
@ -0,0 +1,64 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'action_view/helpers/form_helper'
|
||||
|
||||
class Redmine::Views::LabelledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
include Redmine::I18n
|
||||
|
||||
(field_helpers.map(&:to_s) - %w(radio_button hidden_field fields_for check_box label) +
|
||||
%w(date_select)).each do |selector|
|
||||
src = <<-END_SRC
|
||||
def #{selector}(field, options = {})
|
||||
label_for_field(field, options) + super(field, options.except(:label)).html_safe
|
||||
end
|
||||
END_SRC
|
||||
class_eval src, __FILE__, __LINE__
|
||||
end
|
||||
|
||||
def check_box(field, options={}, checked_value="1", unchecked_value="0")
|
||||
label_for_field(field, options) + super(field, options.except(:label), checked_value, unchecked_value).html_safe
|
||||
end
|
||||
|
||||
def select(field, choices, options = {}, html_options = {})
|
||||
label_for_field(field, options) + super(field, choices, options, html_options.except(:label)).html_safe
|
||||
end
|
||||
|
||||
def time_zone_select(field, priority_zones = nil, options = {}, html_options = {})
|
||||
label_for_field(field, options) + super(field, priority_zones, options, html_options.except(:label)).html_safe
|
||||
end
|
||||
|
||||
# A field for entering hours value
|
||||
def hours_field(field, options={})
|
||||
# display the value before type cast when the entered value is not valid
|
||||
if @object.errors[field].blank?
|
||||
options = options.merge(:value => format_hours(@object.send field))
|
||||
end
|
||||
text_field field, options
|
||||
end
|
||||
|
||||
# Returns a label tag for the given field
|
||||
def label_for_field(field, options = {})
|
||||
return ''.html_safe if options.delete(:no_label)
|
||||
text = options[:label].is_a?(Symbol) ? l(options[:label]) : options[:label]
|
||||
text ||= l(("field_" + field.to_s.gsub(/\_id$/, "")).to_sym)
|
||||
text += @template.content_tag("span", " *", :class => "required") if options.delete(:required)
|
||||
@template.content_tag("label", text.html_safe,
|
||||
:class => (@object && @object.errors[field].present? ? "error" : nil),
|
||||
:for => (@object_name.to_s + "_" + field.to_s))
|
||||
end
|
||||
end
|
43
lib/redmine/views/other_formats_builder.rb
Normal file
43
lib/redmine/views/other_formats_builder.rb
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module Views
|
||||
class OtherFormatsBuilder
|
||||
def initialize(view)
|
||||
@view = view
|
||||
end
|
||||
|
||||
def link_to(name, options={})
|
||||
url = { :format => name.to_s.downcase }.merge(options.delete(:url) || {}).except('page')
|
||||
caption = options.delete(:caption) || name
|
||||
html_options = { :class => name.to_s.downcase, :rel => 'nofollow' }.merge(options)
|
||||
@view.content_tag('span', @view.link_to(caption, url, html_options))
|
||||
end
|
||||
|
||||
# Preserves query parameters
|
||||
def link_to_with_query_parameters(name, url={}, options={})
|
||||
params = @view.request.query_parameters.except(:page, :format).except(*url.keys)
|
||||
url = {:params => params, :page => nil, :format => name.to_s.downcase}.merge(url)
|
||||
|
||||
caption = options.delete(:caption) || name
|
||||
html_options = { :class => name.to_s.downcase, :rel => 'nofollow' }.merge(options)
|
||||
@view.content_tag('span', @view.link_to(caption, url, html_options))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
200
lib/redmine/wiki_formatting.rb
Normal file
200
lib/redmine/wiki_formatting.rb
Normal file
|
@ -0,0 +1,200 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'digest/md5'
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
class StaleSectionError < Exception; end
|
||||
|
||||
@@formatters = {}
|
||||
|
||||
class << self
|
||||
def map
|
||||
yield self
|
||||
end
|
||||
|
||||
def register(name, *args)
|
||||
options = args.last.is_a?(Hash) ? args.pop : {}
|
||||
name = name.to_s
|
||||
raise ArgumentError, "format name '#{name}' is already taken" if @@formatters[name]
|
||||
|
||||
formatter, helper, parser = args.any? ?
|
||||
args :
|
||||
%w(Formatter Helper HtmlParser).map {|m| "Redmine::WikiFormatting::#{name.classify}::#{m}".constantize rescue nil}
|
||||
|
||||
raise "A formatter class is required" if formatter.nil?
|
||||
|
||||
@@formatters[name] = {
|
||||
:formatter => formatter,
|
||||
:helper => helper,
|
||||
:html_parser => parser,
|
||||
:label => options[:label] || name.humanize
|
||||
}
|
||||
end
|
||||
|
||||
def formatter
|
||||
formatter_for(Setting.text_formatting)
|
||||
end
|
||||
|
||||
def html_parser
|
||||
html_parser_for(Setting.text_formatting)
|
||||
end
|
||||
|
||||
def formatter_for(name)
|
||||
entry = @@formatters[name.to_s]
|
||||
(entry && entry[:formatter]) || Redmine::WikiFormatting::NullFormatter::Formatter
|
||||
end
|
||||
|
||||
def helper_for(name)
|
||||
entry = @@formatters[name.to_s]
|
||||
(entry && entry[:helper]) || Redmine::WikiFormatting::NullFormatter::Helper
|
||||
end
|
||||
|
||||
def html_parser_for(name)
|
||||
entry = @@formatters[name.to_s]
|
||||
(entry && entry[:html_parser]) || Redmine::WikiFormatting::HtmlParser
|
||||
end
|
||||
|
||||
def format_names
|
||||
@@formatters.keys.map
|
||||
end
|
||||
|
||||
def formats_for_select
|
||||
@@formatters.map {|name, options| [options[:label], name]}
|
||||
end
|
||||
|
||||
def to_html(format, text, options = {})
|
||||
text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, text, options[:object], options[:attribute])
|
||||
# Text retrieved from the cache store may be frozen
|
||||
# We need to dup it so we can do in-place substitutions with gsub!
|
||||
cache_store.fetch cache_key do
|
||||
formatter_for(format).new(text).to_html
|
||||
end.dup
|
||||
else
|
||||
formatter_for(format).new(text).to_html
|
||||
end
|
||||
text
|
||||
end
|
||||
|
||||
# Returns true if the text formatter supports single section edit
|
||||
def supports_section_edit?
|
||||
(formatter.instance_methods & ['update_section', :update_section]).any?
|
||||
end
|
||||
|
||||
# Returns a cache key for the given text +format+, +text+, +object+ and +attribute+ or nil if no caching should be done
|
||||
def cache_key_for(format, text, object, attribute)
|
||||
if object && attribute && !object.new_record? && format.present?
|
||||
"formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{Digest::MD5.hexdigest text}"
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the cache store used to cache HTML output
|
||||
def cache_store
|
||||
ActionController::Base.cache_store
|
||||
end
|
||||
end
|
||||
|
||||
module LinksHelper
|
||||
AUTO_LINK_RE = %r{
|
||||
( # leading text
|
||||
<\w+[^>]*?>| # leading HTML tag, or
|
||||
[\s\(\[,;]| # leading punctuation, or
|
||||
^ # beginning of line
|
||||
)
|
||||
(
|
||||
(?:https?://)| # protocol spec, or
|
||||
(?:s?ftps?://)|
|
||||
(?:www\.) # www.*
|
||||
)
|
||||
(
|
||||
([^<]\S*?) # url
|
||||
(\/)? # slash
|
||||
)
|
||||
((?:>)?|[^[:alnum:]_\=\/;\(\)]*?) # post
|
||||
(?=<|\s|$)
|
||||
}x unless const_defined?(:AUTO_LINK_RE)
|
||||
|
||||
# Destructively replaces urls into clickable links
|
||||
def auto_link!(text)
|
||||
text.gsub!(AUTO_LINK_RE) do
|
||||
all, leading, proto, url, post = $&, $1, $2, $3, $6
|
||||
if leading =~ /<a\s/i || leading =~ /![<>=]?/
|
||||
# don't replace URLs that are already linked
|
||||
# and URLs prefixed with ! !> !< != (textile images)
|
||||
all
|
||||
else
|
||||
# Idea below : an URL with unbalanced parenthesis and
|
||||
# ending by ')' is put into external parenthesis
|
||||
if ( url[-1]==?) and ((url.count("(") - url.count(")")) < 0 ) )
|
||||
url=url[0..-2] # discard closing parenthesis from url
|
||||
post = ")"+post # add closing parenthesis to post
|
||||
end
|
||||
content = proto + url
|
||||
href = "#{proto=="www."?"http://www.":proto}#{url}"
|
||||
%(#{leading}<a class="external" href="#{ERB::Util.html_escape href}">#{ERB::Util.html_escape content}</a>#{post}).html_safe
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Destructively replaces email addresses into clickable links
|
||||
def auto_mailto!(text)
|
||||
text.gsub!(/((?<!@)\b[\w\.!#\$%\-+.\/]+@[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+)/) do
|
||||
mail = $1
|
||||
if text.match(/<a\b[^>]*>(.*)(#{Regexp.escape(mail)})(.*)<\/a>/)
|
||||
mail
|
||||
else
|
||||
%(<a class="email" href="mailto:#{ERB::Util.html_escape mail}">#{ERB::Util.html_escape mail}</a>).html_safe
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Default formatter module
|
||||
module NullFormatter
|
||||
class Formatter
|
||||
include ActionView::Helpers::TagHelper
|
||||
include ActionView::Helpers::TextHelper
|
||||
include ActionView::Helpers::UrlHelper
|
||||
include Redmine::WikiFormatting::LinksHelper
|
||||
|
||||
def initialize(text)
|
||||
@text = text
|
||||
end
|
||||
|
||||
def to_html(*args)
|
||||
t = CGI::escapeHTML(@text)
|
||||
auto_link!(t)
|
||||
auto_mailto!(t)
|
||||
simple_format(t, {}, :sanitize => false)
|
||||
end
|
||||
end
|
||||
|
||||
module Helper
|
||||
def wikitoolbar_for(field_id)
|
||||
end
|
||||
|
||||
def heads_for_wiki_formatter
|
||||
end
|
||||
|
||||
def initial_page_content(page)
|
||||
page.pretty_title.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
62
lib/redmine/wiki_formatting/html_parser.rb
Normal file
62
lib/redmine/wiki_formatting/html_parser.rb
Normal file
|
@ -0,0 +1,62 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'loofah/helpers'
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
class HtmlParser
|
||||
|
||||
class_attribute :tags
|
||||
self.tags = {
|
||||
'br' => {:post => "\n"},
|
||||
'style' => ''
|
||||
}
|
||||
|
||||
def self.to_text(html)
|
||||
html = html.gsub(/[\n\r]/, '').squeeze(' ')
|
||||
|
||||
doc = Loofah.document(html)
|
||||
doc.scrub!(WikiTags.new(tags))
|
||||
doc.scrub!(:newline_block_elements)
|
||||
|
||||
Loofah::Helpers.remove_extraneous_whitespace(doc.text).strip
|
||||
end
|
||||
|
||||
class WikiTags < ::Loofah::Scrubber
|
||||
def initialize(tags_to_text)
|
||||
@direction = :bottom_up
|
||||
@tags_to_text = tags_to_text || {}
|
||||
end
|
||||
|
||||
def scrub(node)
|
||||
formatting = @tags_to_text[node.name]
|
||||
case formatting
|
||||
when Hash
|
||||
node.add_next_sibling Nokogiri::XML::Text.new("#{formatting[:pre]}#{node.content}#{formatting[:post]}", node.document)
|
||||
node.remove
|
||||
when String
|
||||
node.add_next_sibling Nokogiri::XML::Text.new(formatting, node.document)
|
||||
node.remove
|
||||
else
|
||||
CONTINUE
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
256
lib/redmine/wiki_formatting/macros.rb
Normal file
256
lib/redmine/wiki_formatting/macros.rb
Normal file
|
@ -0,0 +1,256 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
module Macros
|
||||
module Definitions
|
||||
# Returns true if +name+ is the name of an existing macro
|
||||
def macro_exists?(name)
|
||||
Redmine::WikiFormatting::Macros.available_macros.key?(name.to_sym)
|
||||
end
|
||||
|
||||
def exec_macro(name, obj, args, text)
|
||||
macro_options = Redmine::WikiFormatting::Macros.available_macros[name.to_sym]
|
||||
return unless macro_options
|
||||
|
||||
method_name = "macro_#{name}"
|
||||
unless macro_options[:parse_args] == false
|
||||
args = args.split(',').map(&:strip)
|
||||
end
|
||||
|
||||
begin
|
||||
if self.class.instance_method(method_name).arity == 3
|
||||
send(method_name, obj, args, text)
|
||||
elsif text
|
||||
raise "This macro does not accept a block of text"
|
||||
else
|
||||
send(method_name, obj, args)
|
||||
end
|
||||
rescue => e
|
||||
"<div class=\"flash error\">Error executing the <strong>#{h name}</strong> macro (#{h e.to_s})</div>".html_safe
|
||||
end
|
||||
end
|
||||
|
||||
def extract_macro_options(args, *keys)
|
||||
options = {}
|
||||
while args.last.to_s.strip =~ %r{^(.+?)\=(.+)$} && keys.include?($1.downcase.to_sym)
|
||||
options[$1.downcase.to_sym] = $2
|
||||
args.pop
|
||||
end
|
||||
return [args, options]
|
||||
end
|
||||
end
|
||||
|
||||
@@available_macros = {}
|
||||
mattr_accessor :available_macros
|
||||
|
||||
class << self
|
||||
# Plugins can use this method to define new macros:
|
||||
#
|
||||
# Redmine::WikiFormatting::Macros.register do
|
||||
# desc "This is my macro"
|
||||
# macro :my_macro do |obj, args|
|
||||
# "My macro output"
|
||||
# end
|
||||
#
|
||||
# desc "This is my macro that accepts a block of text"
|
||||
# macro :my_macro do |obj, args, text|
|
||||
# "My macro output"
|
||||
# end
|
||||
# end
|
||||
def register(&block)
|
||||
class_eval(&block) if block_given?
|
||||
end
|
||||
|
||||
# Defines a new macro with the given name, options and block.
|
||||
#
|
||||
# Options:
|
||||
# * :desc - A description of the macro
|
||||
# * :parse_args => false - Disables arguments parsing (the whole arguments
|
||||
# string is passed to the macro)
|
||||
#
|
||||
# Macro blocks accept 2 or 3 arguments:
|
||||
# * obj: the object that is rendered (eg. an Issue, a WikiContent...)
|
||||
# * args: macro arguments
|
||||
# * text: the block of text given to the macro (should be present only if the
|
||||
# macro accepts a block of text). text is a String or nil if the macro is
|
||||
# invoked without a block of text.
|
||||
#
|
||||
# Examples:
|
||||
# By default, when the macro is invoked, the comma separated list of arguments
|
||||
# is split and passed to the macro block as an array. If no argument is given
|
||||
# the macro will be invoked with an empty array:
|
||||
#
|
||||
# macro :my_macro do |obj, args|
|
||||
# # args is an array
|
||||
# # and this macro do not accept a block of text
|
||||
# end
|
||||
#
|
||||
# You can disable arguments spliting with the :parse_args => false option. In
|
||||
# this case, the full string of arguments is passed to the macro:
|
||||
#
|
||||
# macro :my_macro, :parse_args => false do |obj, args|
|
||||
# # args is a string
|
||||
# end
|
||||
#
|
||||
# Macro can optionally accept a block of text:
|
||||
#
|
||||
# macro :my_macro do |obj, args, text|
|
||||
# # this macro accepts a block of text
|
||||
# end
|
||||
#
|
||||
# Macros are invoked in formatted text using double curly brackets. Arguments
|
||||
# must be enclosed in parenthesis if any. A new line after the macro name or the
|
||||
# arguments starts the block of text that will be passe to the macro (invoking
|
||||
# a macro that do not accept a block of text with some text will fail).
|
||||
# Examples:
|
||||
#
|
||||
# No arguments:
|
||||
# {{my_macro}}
|
||||
#
|
||||
# With arguments:
|
||||
# {{my_macro(arg1, arg2)}}
|
||||
#
|
||||
# With a block of text:
|
||||
# {{my_macro
|
||||
# multiple lines
|
||||
# of text
|
||||
# }}
|
||||
#
|
||||
# With arguments and a block of text
|
||||
# {{my_macro(arg1, arg2)
|
||||
# multiple lines
|
||||
# of text
|
||||
# }}
|
||||
#
|
||||
# If a block of text is given, the closing tag }} must be at the start of a new line.
|
||||
def macro(name, options={}, &block)
|
||||
options.assert_valid_keys(:desc, :parse_args)
|
||||
unless name.to_s.match(/\A\w+\z/)
|
||||
raise "Invalid macro name: #{name} (only 0-9, A-Z, a-z and _ characters are accepted)"
|
||||
end
|
||||
unless block_given?
|
||||
raise "Can not create a macro without a block!"
|
||||
end
|
||||
name = name.to_s.downcase.to_sym
|
||||
available_macros[name] = {:desc => @@desc || ''}.merge(options)
|
||||
@@desc = nil
|
||||
Definitions.send :define_method, "macro_#{name}", &block
|
||||
end
|
||||
|
||||
# Sets description for the next macro to be defined
|
||||
def desc(txt)
|
||||
@@desc = txt
|
||||
end
|
||||
end
|
||||
|
||||
# Builtin macros
|
||||
desc "Sample macro."
|
||||
macro :hello_world do |obj, args, text|
|
||||
h("Hello world! Object: #{obj.class.name}, " +
|
||||
(args.empty? ? "Called with no argument" : "Arguments: #{args.join(', ')}") +
|
||||
" and " + (text.present? ? "a #{text.size} bytes long block of text." : "no block of text.")
|
||||
)
|
||||
end
|
||||
|
||||
desc "Displays a list of all available macros, including description if available."
|
||||
macro :macro_list do |obj, args|
|
||||
out = ''.html_safe
|
||||
@@available_macros.each do |macro, options|
|
||||
out << content_tag('dt', content_tag('code', macro.to_s))
|
||||
out << content_tag('dd', content_tag('pre', options[:desc]))
|
||||
end
|
||||
content_tag('dl', out)
|
||||
end
|
||||
|
||||
desc "Displays a list of child pages. With no argument, it displays the child pages of the current wiki page. Examples:\n\n" +
|
||||
"{{child_pages}} -- can be used from a wiki page only\n" +
|
||||
"{{child_pages(depth=2)}} -- display 2 levels nesting only\n" +
|
||||
"{{child_pages(Foo)}} -- lists all children of page Foo\n" +
|
||||
"{{child_pages(Foo, parent=1)}} -- same as above with a link to page Foo"
|
||||
macro :child_pages do |obj, args|
|
||||
args, options = extract_macro_options(args, :parent, :depth)
|
||||
options[:depth] = options[:depth].to_i if options[:depth].present?
|
||||
|
||||
page = nil
|
||||
if args.size > 0
|
||||
page = Wiki.find_page(args.first.to_s, :project => @project)
|
||||
elsif obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)
|
||||
page = obj.page
|
||||
else
|
||||
raise 'With no argument, this macro can be called from wiki pages only.'
|
||||
end
|
||||
raise 'Page not found' if page.nil? || !User.current.allowed_to?(:view_wiki_pages, page.wiki.project)
|
||||
pages = page.self_and_descendants(options[:depth]).group_by(&:parent_id)
|
||||
render_page_hierarchy(pages, options[:parent] ? page.parent_id : page.id)
|
||||
end
|
||||
|
||||
desc "Includes a wiki page. Examples:\n\n" +
|
||||
"{{include(Foo)}}\n" +
|
||||
"{{include(projectname:Foo)}} -- to include a page of a specific project wiki"
|
||||
macro :include do |obj, args|
|
||||
page = Wiki.find_page(args.first.to_s, :project => @project)
|
||||
raise 'Page not found' if page.nil? || !User.current.allowed_to?(:view_wiki_pages, page.wiki.project)
|
||||
@included_wiki_pages ||= []
|
||||
raise 'Circular inclusion detected' if @included_wiki_pages.include?(page.id)
|
||||
@included_wiki_pages << page.id
|
||||
out = textilizable(page.content, :text, :attachments => page.attachments, :headings => false)
|
||||
@included_wiki_pages.pop
|
||||
out
|
||||
end
|
||||
|
||||
desc "Inserts of collapsed block of text. Examples:\n\n" +
|
||||
"{{collapse\nThis is a block of text that is collapsed by default.\nIt can be expanded by clicking a link.\n}}\n\n" +
|
||||
"{{collapse(View details...)\nWith custom link text.\n}}"
|
||||
macro :collapse do |obj, args, text|
|
||||
html_id = "collapse-#{Redmine::Utils.random_hex(4)}"
|
||||
show_label = args[0] || l(:button_show)
|
||||
hide_label = args[1] || args[0] || l(:button_hide)
|
||||
js = "$('##{html_id}-show, ##{html_id}-hide').toggle(); $('##{html_id}').fadeToggle(150);"
|
||||
out = ''.html_safe
|
||||
out << link_to_function(show_label, js, :id => "#{html_id}-show", :class => 'collapsible collapsed')
|
||||
out << link_to_function(hide_label, js, :id => "#{html_id}-hide", :class => 'collapsible', :style => 'display:none;')
|
||||
out << content_tag('div', textilizable(text, :object => obj, :headings => false), :id => html_id, :class => 'collapsed-text', :style => 'display:none;')
|
||||
out
|
||||
end
|
||||
|
||||
desc "Displays a clickable thumbnail of an attached image. Examples:\n\n" +
|
||||
"{{thumbnail(image.png)}}\n" +
|
||||
"{{thumbnail(image.png, size=300, title=Thumbnail)}} -- with custom title and size"
|
||||
macro :thumbnail do |obj, args|
|
||||
args, options = extract_macro_options(args, :size, :title)
|
||||
filename = args.first
|
||||
raise 'Filename required' unless filename.present?
|
||||
size = options[:size]
|
||||
raise 'Invalid size parameter' unless size.nil? || size.match(/^\d+$/)
|
||||
size = size.to_i
|
||||
size = nil unless size > 0
|
||||
if obj && obj.respond_to?(:attachments) && attachment = Attachment.latest_attach(obj.attachments, filename)
|
||||
title = options[:title] || attachment.title
|
||||
thumbnail_url = url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment, :size => size, :only_path => @only_path)
|
||||
image_url = url_for(:controller => 'attachments', :action => 'show', :id => attachment, :only_path => @only_path)
|
||||
|
||||
img = image_tag(thumbnail_url, :alt => attachment.filename)
|
||||
link_to(img, image_url, :class => 'thumbnail', :title => title)
|
||||
else
|
||||
raise "Attachment #{filename} not found"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
151
lib/redmine/wiki_formatting/markdown/formatter.rb
Normal file
151
lib/redmine/wiki_formatting/markdown/formatter.rb
Normal file
|
@ -0,0 +1,151 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'cgi'
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
module Markdown
|
||||
class HTML < Redcarpet::Render::HTML
|
||||
include ActionView::Helpers::TagHelper
|
||||
include Redmine::Helpers::URL
|
||||
|
||||
def link(link, title, content)
|
||||
return nil unless uri_with_safe_scheme?(link)
|
||||
|
||||
css = nil
|
||||
unless link && link.starts_with?('/')
|
||||
css = 'external'
|
||||
end
|
||||
content_tag('a', content.to_s.html_safe, :href => link, :title => title, :class => css)
|
||||
end
|
||||
|
||||
def block_code(code, language)
|
||||
if language.present? && Redmine::SyntaxHighlighting.language_supported?(language)
|
||||
"<pre><code class=\"#{CGI.escapeHTML language} syntaxhl\">" +
|
||||
Redmine::SyntaxHighlighting.highlight_by_language(code, language) +
|
||||
"</code></pre>"
|
||||
else
|
||||
"<pre>" + CGI.escapeHTML(code) + "</pre>"
|
||||
end
|
||||
end
|
||||
|
||||
def image(link, title, alt_text)
|
||||
return unless uri_with_safe_scheme?(link)
|
||||
|
||||
tag('img', :src => link, :alt => alt_text || "", :title => title)
|
||||
end
|
||||
end
|
||||
|
||||
class Formatter
|
||||
def initialize(text)
|
||||
@text = text
|
||||
end
|
||||
|
||||
def to_html(*args)
|
||||
html = formatter.render(@text)
|
||||
# restore wiki links eg. [[Foo]]
|
||||
html.gsub!(%r{\[<a href="(.*?)">(.*?)</a>\]}) do
|
||||
"[[#{$2}]]"
|
||||
end
|
||||
# restore Redmine links with double-quotes, eg. version:"1.0"
|
||||
html.gsub!(/(\w):"(.+?)"/) do
|
||||
"#{$1}:\"#{$2}\""
|
||||
end
|
||||
# restore user links with @ in login name eg. [@jsmith@somenet.foo]
|
||||
html.gsub!(%r{[@\A]<a href="mailto:(.*?)">(.*?)</a>}) do
|
||||
"@#{$2}"
|
||||
end
|
||||
html
|
||||
end
|
||||
|
||||
def get_section(index)
|
||||
section = extract_sections(index)[1]
|
||||
hash = Digest::MD5.hexdigest(section)
|
||||
return section, hash
|
||||
end
|
||||
|
||||
def update_section(index, update, hash=nil)
|
||||
t = extract_sections(index)
|
||||
if hash.present? && hash != Digest::MD5.hexdigest(t[1])
|
||||
raise Redmine::WikiFormatting::StaleSectionError
|
||||
end
|
||||
t[1] = update unless t[1].blank?
|
||||
t.reject(&:blank?).join "\n\n"
|
||||
end
|
||||
|
||||
def extract_sections(index)
|
||||
sections = ['', '', '']
|
||||
offset = 0
|
||||
i = 0
|
||||
l = 1
|
||||
inside_pre = false
|
||||
@text.split(/(^(?:.+\r?\n\r?(?:\=+|\-+)|#+.+|~~~.*)\s*$)/).each do |part|
|
||||
level = nil
|
||||
if part =~ /\A~{3,}(\S+)?\s*$/
|
||||
if $1
|
||||
if !inside_pre
|
||||
inside_pre = true
|
||||
end
|
||||
else
|
||||
inside_pre = !inside_pre
|
||||
end
|
||||
elsif inside_pre
|
||||
# nop
|
||||
elsif part =~ /\A(#+).+/
|
||||
level = $1.size
|
||||
elsif part =~ /\A.+\r?\n\r?(\=+|\-+)\s*$/
|
||||
level = $1.include?('=') ? 1 : 2
|
||||
end
|
||||
if level
|
||||
i += 1
|
||||
if offset == 0 && i == index
|
||||
# entering the requested section
|
||||
offset = 1
|
||||
l = level
|
||||
elsif offset == 1 && i > index && level <= l
|
||||
# leaving the requested section
|
||||
offset = 2
|
||||
end
|
||||
end
|
||||
sections[offset] << part
|
||||
end
|
||||
sections.map(&:strip)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def formatter
|
||||
@@formatter ||= Redcarpet::Markdown.new(
|
||||
Redmine::WikiFormatting::Markdown::HTML.new(
|
||||
:filter_html => true,
|
||||
:hard_wrap => true
|
||||
),
|
||||
:autolink => true,
|
||||
:fenced_code_blocks => true,
|
||||
:space_after_headers => true,
|
||||
:tables => true,
|
||||
:strikethrough => true,
|
||||
:superscript => true,
|
||||
:no_intra_emphasis => true,
|
||||
:footnotes => true
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
47
lib/redmine/wiki_formatting/markdown/helper.rb
Normal file
47
lib/redmine/wiki_formatting/markdown/helper.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
module Markdown
|
||||
module Helper
|
||||
def wikitoolbar_for(field_id)
|
||||
heads_for_wiki_formatter
|
||||
url = "#{Redmine::Utils.relative_url_root}/help/#{current_language.to_s.downcase}/wiki_syntax_markdown.html"
|
||||
javascript_tag("var wikiToolbar = new jsToolBar(document.getElementById('#{field_id}')); wikiToolbar.setHelpLink('#{escape_javascript url}'); wikiToolbar.draw();")
|
||||
end
|
||||
|
||||
def initial_page_content(page)
|
||||
"# #{@page.pretty_title}"
|
||||
end
|
||||
|
||||
def heads_for_wiki_formatter
|
||||
unless @heads_for_wiki_formatter_included
|
||||
content_for :header_tags do
|
||||
javascript_include_tag('jstoolbar/jstoolbar') +
|
||||
javascript_include_tag('jstoolbar/markdown') +
|
||||
javascript_include_tag("jstoolbar/lang/jstoolbar-#{current_language.to_s.downcase}") +
|
||||
javascript_tag("var wikiImageMimeTypes = #{Redmine::MimeType.by_type('image').to_json};") +
|
||||
stylesheet_link_tag('jstoolbar')
|
||||
end
|
||||
@heads_for_wiki_formatter_included = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
39
lib/redmine/wiki_formatting/markdown/html_parser.rb
Normal file
39
lib/redmine/wiki_formatting/markdown/html_parser.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
module Markdown
|
||||
class HtmlParser < Redmine::WikiFormatting::HtmlParser
|
||||
|
||||
self.tags = tags.merge(
|
||||
'b' => {:pre => '**', :post => '**'},
|
||||
'strong' => {:pre => '**', :post => '**'},
|
||||
'i' => {:pre => '_', :post => '_'},
|
||||
'em' => {:pre => '_', :post => '_'},
|
||||
'strike' => {:pre => '~~', :post => '~~'},
|
||||
'h1' => {:pre => "\n\n# ", :post => "\n\n"},
|
||||
'h2' => {:pre => "\n\n## ", :post => "\n\n"},
|
||||
'h3' => {:pre => "\n\n### ", :post => "\n\n"},
|
||||
'h4' => {:pre => "\n\n#### ", :post => "\n\n"},
|
||||
'h5' => {:pre => "\n\n##### ", :post => "\n\n"},
|
||||
'h6' => {:pre => "\n\n###### ", :post => "\n\n"}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
140
lib/redmine/wiki_formatting/textile/formatter.rb
Normal file
140
lib/redmine/wiki_formatting/textile/formatter.rb
Normal file
|
@ -0,0 +1,140 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require File.expand_path('../redcloth3', __FILE__)
|
||||
require 'digest/md5'
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
module Textile
|
||||
class Formatter < RedCloth3
|
||||
include ActionView::Helpers::TagHelper
|
||||
include Redmine::WikiFormatting::LinksHelper
|
||||
|
||||
alias :inline_auto_link :auto_link!
|
||||
alias :inline_auto_mailto :auto_mailto!
|
||||
|
||||
# auto_link rule after textile rules so that it doesn't break !image_url! tags
|
||||
RULES = [:textile, :block_markdown_rule, :inline_auto_link, :inline_auto_mailto]
|
||||
|
||||
def initialize(*args)
|
||||
super
|
||||
self.hard_breaks=true
|
||||
self.no_span_caps=true
|
||||
self.filter_styles=false
|
||||
end
|
||||
|
||||
def to_html(*rules)
|
||||
@toc = []
|
||||
super(*RULES).to_s
|
||||
end
|
||||
|
||||
def get_section(index)
|
||||
section = extract_sections(index)[1]
|
||||
hash = Digest::MD5.hexdigest(section)
|
||||
return section, hash
|
||||
end
|
||||
|
||||
def update_section(index, update, hash=nil)
|
||||
t = extract_sections(index)
|
||||
if hash.present? && hash != Digest::MD5.hexdigest(t[1])
|
||||
raise Redmine::WikiFormatting::StaleSectionError
|
||||
end
|
||||
t[1] = update unless t[1].blank?
|
||||
t.reject(&:blank?).join "\n\n"
|
||||
end
|
||||
|
||||
def extract_sections(index)
|
||||
@pre_list = []
|
||||
text = self.dup
|
||||
rip_offtags text, false, false
|
||||
before = ''
|
||||
s = ''
|
||||
after = ''
|
||||
i = 0
|
||||
l = 1
|
||||
started = false
|
||||
ended = false
|
||||
text.scan(/(((?:.*?)(\A|\r?\n\s*\r?\n))(h(\d+)(#{A}#{C})\.(?::(\S+))?[ \t](.*?)$)|.*)/m).each do |all, content, lf, heading, level|
|
||||
if heading.nil?
|
||||
if ended
|
||||
after << all
|
||||
elsif started
|
||||
s << all
|
||||
else
|
||||
before << all
|
||||
end
|
||||
break
|
||||
end
|
||||
i += 1
|
||||
if ended
|
||||
after << all
|
||||
elsif i == index
|
||||
l = level.to_i
|
||||
before << content
|
||||
s << heading
|
||||
started = true
|
||||
elsif i > index
|
||||
s << content
|
||||
if level.to_i > l
|
||||
s << heading
|
||||
else
|
||||
after << heading
|
||||
ended = true
|
||||
end
|
||||
else
|
||||
before << all
|
||||
end
|
||||
end
|
||||
sections = [before.strip, s.strip, after.strip]
|
||||
sections.each {|section| smooth_offtags_without_code_highlighting section}
|
||||
sections
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Patch for RedCloth. Fixed in RedCloth r128 but _why hasn't released it yet.
|
||||
# <a href="http://code.whytheluckystiff.net/redcloth/changeset/128">http://code.whytheluckystiff.net/redcloth/changeset/128</a>
|
||||
def hard_break( text )
|
||||
text.gsub!( /(.)\n(?!\n|\Z| *([#*=]+(\s|$)|[{|]))/, "\\1<br />" ) if hard_breaks
|
||||
end
|
||||
|
||||
alias :smooth_offtags_without_code_highlighting :smooth_offtags
|
||||
# Patch to add code highlighting support to RedCloth
|
||||
def smooth_offtags( text )
|
||||
unless @pre_list.empty?
|
||||
## replace <pre> content
|
||||
text.gsub!(/<redpre#(\d+)>/) do
|
||||
content = @pre_list[$1.to_i]
|
||||
if content.match(/<code\s+class="(\w+)">\s?(.+)/m)
|
||||
language = $1
|
||||
text = $2
|
||||
if Redmine::SyntaxHighlighting.language_supported?(language)
|
||||
content = "<code class=\"#{language} syntaxhl\">" +
|
||||
Redmine::SyntaxHighlighting.highlight_by_language(text, language)
|
||||
else
|
||||
content = "<code>#{ERB::Util.h(text)}"
|
||||
end
|
||||
end
|
||||
content
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
47
lib/redmine/wiki_formatting/textile/helper.rb
Normal file
47
lib/redmine/wiki_formatting/textile/helper.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
module Textile
|
||||
module Helper
|
||||
def wikitoolbar_for(field_id)
|
||||
heads_for_wiki_formatter
|
||||
# Is there a simple way to link to a public resource?
|
||||
url = "#{Redmine::Utils.relative_url_root}/help/#{current_language.to_s.downcase}/wiki_syntax_textile.html"
|
||||
javascript_tag("var wikiToolbar = new jsToolBar(document.getElementById('#{field_id}')); wikiToolbar.setHelpLink('#{escape_javascript url}'); wikiToolbar.draw();")
|
||||
end
|
||||
|
||||
def initial_page_content(page)
|
||||
"h1. #{@page.pretty_title}"
|
||||
end
|
||||
|
||||
def heads_for_wiki_formatter
|
||||
unless @heads_for_wiki_formatter_included
|
||||
content_for :header_tags do
|
||||
javascript_include_tag('jstoolbar/jstoolbar-textile.min') +
|
||||
javascript_include_tag("jstoolbar/lang/jstoolbar-#{current_language.to_s.downcase}") +
|
||||
javascript_tag("var wikiImageMimeTypes = #{Redmine::MimeType.by_type('image').to_json};") +
|
||||
stylesheet_link_tag('jstoolbar')
|
||||
end
|
||||
@heads_for_wiki_formatter_included = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
40
lib/redmine/wiki_formatting/textile/html_parser.rb
Normal file
40
lib/redmine/wiki_formatting/textile/html_parser.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
module Redmine
|
||||
module WikiFormatting
|
||||
module Textile
|
||||
class HtmlParser < Redmine::WikiFormatting::HtmlParser
|
||||
|
||||
self.tags = tags.merge(
|
||||
'b' => {:pre => '*', :post => '*'},
|
||||
'strong' => {:pre => '*', :post => '*'},
|
||||
'i' => {:pre => '_', :post => '_'},
|
||||
'em' => {:pre => '_', :post => '_'},
|
||||
'u' => {:pre => '+', :post => '+'},
|
||||
'strike' => {:pre => '-', :post => '-'},
|
||||
'h1' => {:pre => "\n\nh1. ", :post => "\n\n"},
|
||||
'h2' => {:pre => "\n\nh2. ", :post => "\n\n"},
|
||||
'h3' => {:pre => "\n\nh3. ", :post => "\n\n"},
|
||||
'h4' => {:pre => "\n\nh4. ", :post => "\n\n"},
|
||||
'h5' => {:pre => "\n\nh5. ", :post => "\n\n"},
|
||||
'h6' => {:pre => "\n\nh6. ", :post => "\n\n"}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
1219
lib/redmine/wiki_formatting/textile/redcloth3.rb
Normal file
1219
lib/redmine/wiki_formatting/textile/redcloth3.rb
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue