Redmine 3.4.4

This commit is contained in:
Manuel Cillero 2018-02-02 22:19:29 +01:00
commit 64924a6376
2112 changed files with 259028 additions and 0 deletions

View 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

View 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
View 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

View 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

View 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
View 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

View 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

View 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
View file

@ -0,0 +1 @@
Dir[File.dirname(__FILE__) + "/core_ext/*.rb"].each { |file| require(file) }

View 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

View file

@ -0,0 +1,5 @@
require File.dirname(__FILE__) + '/date/calculations'
class Date #:nodoc:
include Redmine::CoreExtensions::Date::Calculations
end

View 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

View 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

View 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

View 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
View 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

View 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
View 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
View 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

View 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

View 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

File diff suppressed because it is too large Load diff

View 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

View 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

View 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, '&nbsp;'.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, '&nbsp;'.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, '&nbsp;'.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, '&nbsp;'.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, '&nbsp;'.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

View 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

View 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
View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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

View 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
View 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
View 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) = &#171;
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', '&hellip;'.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) = &#187;
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
View 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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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>"

View 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')

View 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

View 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
View 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
View 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

View 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

View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
)
((?:&gt;)?|[^[: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

View 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

View 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

View 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):&quot;(.+?)&quot;/) 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load diff