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

340
lib/SVG/GPL.txt Normal file
View file

@ -0,0 +1,340 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.

148
lib/SVG/Graph/Bar.rb Normal file
View file

@ -0,0 +1,148 @@
require 'rexml/document'
require 'SVG/Graph/Graph'
require 'SVG/Graph/BarBase'
module SVG
module Graph
# === Create presentation quality SVG bar graphs easily
#
# = Synopsis
#
# require 'SVG/Graph/Bar'
#
# fields = %w(Jan Feb Mar);
# data_sales_02 = [12, 45, 21]
#
# graph = SVG::Graph::Bar.new(
# :height => 500,
# :width => 300,
# :fields => fields
# )
#
# graph.add_data(
# :data => data_sales_02,
# :title => 'Sales 2002'
# )
#
# print "Content-type: image/svg+xml\r\n\r\n"
# print graph.burn
#
# = Description
#
# This object aims to allow you to easily create high quality
# SVG[http://www.w3c.org/tr/svg bar graphs. You can either use the default
# style sheet or supply your own. Either way there are many options which
# can be configured to give you control over how the graph is generated -
# with or without a key, data elements at each point, title, subtitle etc.
#
# = Notes
#
# The default stylesheet handles upto 12 data sets, if you
# use more you must create your own stylesheet and add the
# additional settings for the extra data sets. You will know
# if you go over 12 data sets as they will have no style and
# be in black.
#
# = Examples
#
# * http://germane-software.com/repositories/public/SVG/test/test.rb
#
# = See also
#
# * SVG::Graph::Graph
# * SVG::Graph::BarHorizontal
# * SVG::Graph::Line
# * SVG::Graph::Pie
# * SVG::Graph::Plot
# * SVG::Graph::TimeSeries
class Bar < BarBase
include REXML
# See Graph::initialize and BarBase::set_defaults
def set_defaults
super
self.top_align = self.top_font = 1
end
protected
def get_x_labels
@config[:fields]
end
def get_y_labels
maxvalue = max_value
minvalue = min_value
range = maxvalue - minvalue
top_pad = range == 0 ? 10 : range / 20.0
scale_range = (maxvalue + top_pad) - minvalue
scale_division = scale_divisions || (scale_range / 10.0)
if scale_integers
scale_division = scale_division < 1 ? 1 : scale_division.round
end
rv = []
maxvalue = maxvalue%scale_division == 0 ?
maxvalue : maxvalue + scale_division
minvalue.step( maxvalue, scale_division ) {|v| rv << v}
return rv
end
def x_label_offset( width )
width / 2.0
end
def draw_data
minvalue = min_value
fieldwidth = field_width
unit_size = (@graph_height.to_f - font_size*2*top_font) /
(get_y_labels.max - get_y_labels.min)
bargap = bar_gap ? (fieldwidth < 10 ? fieldwidth / 2 : 10) : 0
bar_width = fieldwidth - bargap
bar_width /= @data.length if stack == :side
x_mod = (@graph_width-bargap)/2 - (stack==:side ? bar_width/2 : 0)
bottom = @graph_height
field_count = 0
@config[:fields].each_index { |i|
dataset_count = 0
for dataset in @data
# cases (assume 0 = +ve):
# value min length
# +ve +ve value - min
# +ve -ve value - 0
# -ve -ve value.abs - 0
value = dataset[:data][i]
left = (fieldwidth * field_count)
length = (value.abs - (minvalue > 0 ? minvalue : 0)) * unit_size
# top is 0 if value is negative
top = bottom - (((value < 0 ? 0 : value) - minvalue) * unit_size)
left += bar_width * dataset_count if stack == :side
@graph.add_element( "rect", {
"x" => left.to_s,
"y" => top.to_s,
"width" => bar_width.to_s,
"height" => length.to_s,
"class" => "fill#{dataset_count+1}"
})
make_datapoint_text(left + bar_width/2.0, top - 6, value.to_s)
dataset_count += 1
end
field_count += 1
}
end
end
end
end

139
lib/SVG/Graph/BarBase.rb Normal file
View file

@ -0,0 +1,139 @@
require 'rexml/document'
require 'SVG/Graph/Graph'
module SVG
module Graph
# = Synopsis
#
# A superclass for bar-style graphs. Do not attempt to instantiate
# directly; use one of the subclasses instead.
#
# = Author
#
# Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
#
# Copyright 2004 Sean E. Russell
# This software is available under the Ruby license[LICENSE.txt]
#
class BarBase < SVG::Graph::Graph
# Ensures that :fields are provided in the configuration.
def initialize config
raise "fields was not supplied or is empty" unless config[:fields] &&
config[:fields].kind_of?(Array) &&
config[:fields].length > 0
super
end
# In addition to the defaults set in Graph::initialize, sets
# [bar_gap] true
# [stack] :overlap
def set_defaults
init_with( :bar_gap => true, :stack => :overlap )
end
# Whether to have a gap between the bars or not, default
# is true, set to false if you don't want gaps.
attr_accessor :bar_gap
# How to stack data sets. :overlap overlaps bars with
# transparent colors, :top stacks bars on top of one another,
# :side stacks the bars side-by-side. Defaults to :overlap.
attr_accessor :stack
protected
def max_value
@data.collect{|x| x[:data].max}.max
end
def min_value
min = 0
if min_scale_value.nil?
min = @data.collect{|x| x[:data].min}.min
min = min > 0 ? 0 : min
else
min = min_scale_value
end
return min
end
def get_css
return <<EOL
/* default fill styles for multiple datasets (probably only use a single dataset on this graph though) */
.key1,.fill1{
fill: #ff0000;
fill-opacity: 0.5;
stroke: none;
stroke-width: 0.5px;
}
.key2,.fill2{
fill: #0000ff;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key3,.fill3{
fill: #00ff00;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key4,.fill4{
fill: #ffcc00;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key5,.fill5{
fill: #00ccff;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key6,.fill6{
fill: #ff00ff;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key7,.fill7{
fill: #00ffff;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key8,.fill8{
fill: #ffff00;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key9,.fill9{
fill: #cc6666;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key10,.fill10{
fill: #663399;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key11,.fill11{
fill: #339900;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key12,.fill12{
fill: #9966FF;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
EOL
end
end
end
end

View file

@ -0,0 +1,149 @@
require 'rexml/document'
require 'SVG/Graph/BarBase'
module SVG
module Graph
# === Create presentation quality SVG horitonzal bar graphs easily
#
# = Synopsis
#
# require 'SVG/Graph/BarHorizontal'
#
# fields = %w(Jan Feb Mar)
# data_sales_02 = [12, 45, 21]
#
# graph = SVG::Graph::BarHorizontal.new({
# :height => 500,
# :width => 300,
# :fields => fields,
# })
#
# graph.add_data({
# :data => data_sales_02,
# :title => 'Sales 2002',
# })
#
# print "Content-type: image/svg+xml\r\n\r\n"
# print graph.burn
#
# = Description
#
# This object aims to allow you to easily create high quality
# SVG horitonzal bar graphs. You can either use the default style sheet
# or supply your own. Either way there are many options which can
# be configured to give you control over how the graph is
# generated - with or without a key, data elements at each point,
# title, subtitle etc.
#
# = Examples
#
# * http://germane-software.com/repositories/public/SVG/test/test.rb
#
# = See also
#
# * SVG::Graph::Graph
# * SVG::Graph::Bar
# * SVG::Graph::Line
# * SVG::Graph::Pie
# * SVG::Graph::Plot
# * SVG::Graph::TimeSeries
#
# == Author
#
# Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
#
# Copyright 2004 Sean E. Russell
# This software is available under the Ruby license[LICENSE.txt]
#
class BarHorizontal < BarBase
# In addition to the defaults set in BarBase::set_defaults, sets
# [rotate_y_labels] true
# [show_x_guidelines] true
# [show_y_guidelines] false
def set_defaults
super
init_with(
:rotate_y_labels => true,
:show_x_guidelines => true,
:show_y_guidelines => false
)
self.right_align = self.right_font = 1
end
protected
def get_x_labels
maxvalue = max_value
minvalue = min_value
range = maxvalue - minvalue
top_pad = range == 0 ? 10 : range / 20.0
scale_range = (maxvalue + top_pad) - minvalue
scale_division = scale_divisions || (scale_range / 10.0)
if scale_integers
scale_division = scale_division < 1 ? 1 : scale_division.round
end
rv = []
maxvalue = maxvalue%scale_division == 0 ?
maxvalue : maxvalue + scale_division
minvalue.step( maxvalue, scale_division ) {|v| rv << v}
return rv
end
def get_y_labels
@config[:fields]
end
def y_label_offset( height )
height / -2.0
end
def draw_data
minvalue = min_value
fieldheight = field_height
unit_size = (@graph_width.to_f - font_size*2*right_font ) /
(get_x_labels.max - get_x_labels.min )
bargap = bar_gap ? (fieldheight < 10 ? fieldheight / 2 : 10) : 0
bar_height = fieldheight - bargap
bar_height /= @data.length if stack == :side
y_mod = (bar_height / 2) + (font_size / 2)
field_count = 1
@config[:fields].each_index { |i|
dataset_count = 0
for dataset in @data
value = dataset[:data][i]
top = @graph_height - (fieldheight * field_count)
top += (bar_height * dataset_count) if stack == :side
# cases (assume 0 = +ve):
# value min length left
# +ve +ve value.abs - min minvalue.abs
# +ve -ve value.abs - 0 minvalue.abs
# -ve -ve value.abs - 0 minvalue.abs + value
length = (value.abs - (minvalue > 0 ? minvalue : 0)) * unit_size
left = (minvalue.abs + (value < 0 ? value : 0)) * unit_size
@graph.add_element( "rect", {
"x" => left.to_s,
"y" => top.to_s,
"width" => length.to_s,
"height" => bar_height.to_s,
"class" => "fill#{dataset_count+1}"
})
make_datapoint_text(
left+length+5, top+y_mod, value, "text-anchor: start; "
)
dataset_count += 1
end
field_count += 1
}
end
end
end
end

977
lib/SVG/Graph/Graph.rb Normal file
View file

@ -0,0 +1,977 @@
begin
require 'zlib'
rescue
# Zlib not available
end
require 'rexml/document'
module SVG
module Graph
VERSION = '@ANT_VERSION@'
# === Base object for generating SVG Graphs
#
# == Synopsis
#
# This class is only used as a superclass of specialized charts. Do not
# attempt to use this class directly, unless creating a new chart type.
#
# For examples of how to subclass this class, see the existing specific
# subclasses, such as SVG::Graph::Pie.
#
# == Examples
#
# For examples of how to use this package, see either the test files, or
# the documentation for the specific class you want to use.
#
# * file:test/plot.rb
# * file:test/single.rb
# * file:test/test.rb
# * file:test/timeseries.rb
#
# == Description
#
# This package should be used as a base for creating SVG graphs.
#
# == Acknowledgements
#
# Leo Lapworth for creating the SVG::TT::Graph package which this Ruby
# port is based on.
#
# Stephen Morgan for creating the TT template and SVG.
#
# == See
#
# * SVG::Graph::BarHorizontal
# * SVG::Graph::Bar
# * SVG::Graph::Line
# * SVG::Graph::Pie
# * SVG::Graph::Plot
# * SVG::Graph::TimeSeries
#
# == Author
#
# Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
#
# Copyright 2004 Sean E. Russell
# This software is available under the Ruby license[LICENSE.txt]
#
class Graph
include REXML
# Initialize the graph object with the graph settings. You won't
# instantiate this class directly; see the subclass for options.
# [width] 500
# [height] 300
# [show_x_guidelines] false
# [show_y_guidelines] true
# [show_data_values] true
# [min_scale_value] 0
# [show_x_labels] true
# [stagger_x_labels] false
# [rotate_x_labels] false
# [step_x_labels] 1
# [step_include_first_x_label] true
# [show_y_labels] true
# [rotate_y_labels] false
# [scale_integers] false
# [show_x_title] false
# [x_title] 'X Field names'
# [show_y_title] false
# [y_title_text_direction] :bt
# [y_title] 'Y Scale'
# [show_graph_title] false
# [graph_title] 'Graph Title'
# [show_graph_subtitle] false
# [graph_subtitle] 'Graph Sub Title'
# [key] true,
# [key_position] :right, # bottom or righ
# [font_size] 12
# [title_font_size] 16
# [subtitle_font_size] 14
# [x_label_font_size] 12
# [x_title_font_size] 14
# [y_label_font_size] 12
# [y_title_font_size] 14
# [key_font_size] 10
# [no_css] false
# [add_popups] false
def initialize( config )
@config = config
self.top_align = self.top_font = self.right_align = self.right_font = 0
init_with({
:width => 500,
:height => 300,
:show_x_guidelines => false,
:show_y_guidelines => true,
:show_data_values => true,
# :min_scale_value => 0,
:show_x_labels => true,
:stagger_x_labels => false,
:rotate_x_labels => false,
:step_x_labels => 1,
:step_include_first_x_label => true,
:show_y_labels => true,
:rotate_y_labels => false,
:stagger_y_labels => false,
:scale_integers => false,
:show_x_title => false,
:x_title => 'X Field names',
:show_y_title => false,
:y_title_text_direction => :bt,
:y_title => 'Y Scale',
:show_graph_title => false,
:graph_title => 'Graph Title',
:show_graph_subtitle => false,
:graph_subtitle => 'Graph Sub Title',
:key => true,
:key_position => :right, # bottom or right
:font_size =>12,
:title_font_size =>16,
:subtitle_font_size =>14,
:x_label_font_size =>12,
:x_title_font_size =>14,
:y_label_font_size =>12,
:y_title_font_size =>14,
:key_font_size =>10,
:no_css =>false,
:add_popups =>false,
})
set_defaults if respond_to? :set_defaults
init_with config
end
# This method allows you do add data to the graph object.
# It can be called several times to add more data sets in.
#
# data_sales_02 = [12, 45, 21];
#
# graph.add_data({
# :data => data_sales_02,
# :title => 'Sales 2002'
# })
def add_data conf
@data = [] unless defined? @data
if conf[:data] and conf[:data].kind_of? Array
@data << conf
else
raise "No data provided by #{conf.inspect}"
end
end
# This method removes all data from the object so that you can
# reuse it to create a new graph but with the same config options.
#
# graph.clear_data
def clear_data
@data = []
end
# This method processes the template with the data and
# config which has been set and returns the resulting SVG.
#
# This method will croak unless at least one data set has
# been added to the graph object.
#
# print graph.burn
def burn
raise "No data available" unless @data.size > 0
calculations if respond_to? :calculations
start_svg
calculate_graph_dimensions
@foreground = Element.new( "g" )
draw_graph
draw_titles
draw_legend
draw_data
@graph.add_element( @foreground )
style
data = ""
@doc.write( data, 0 )
if @config[:compress]
if Object.const_defined?(:Zlib)
inp, out = IO.pipe
gz = Zlib::GzipWriter.new( out )
gz.write data
gz.close
data = inp.read
else
data << "<!-- Ruby Zlib not available for SVGZ -->";
end
end
return data
end
# Set the height of the graph box, this is the total height
# of the SVG box created - not the graph it self which auto
# scales to fix the space.
attr_accessor :height
# Set the width of the graph box, this is the total width
# of the SVG box created - not the graph it self which auto
# scales to fix the space.
attr_accessor :width
# Set the path to an external stylesheet, set to '' if
# you want to revert back to using the default internal version.
#
# To create an external stylesheet create a graph using the
# default internal version and copy the stylesheet section to
# an external file and edit from there.
attr_accessor :style_sheet
# (Bool) Show the value of each element of data on the graph
attr_accessor :show_data_values
# The point at which the Y axis starts, defaults to '0',
# if set to nil it will default to the minimum data value.
attr_accessor :min_scale_value
# Whether to show labels on the X axis or not, defaults
# to true, set to false if you want to turn them off.
attr_accessor :show_x_labels
# This puts the X labels at alternative levels so if they
# are long field names they will not overlap so easily.
# Default it false, to turn on set to true.
attr_accessor :stagger_x_labels
# This puts the Y labels at alternative levels so if they
# are long field names they will not overlap so easily.
# Default it false, to turn on set to true.
attr_accessor :stagger_y_labels
# This turns the X axis labels by 90 degrees.
# Default it false, to turn on set to true.
attr_accessor :rotate_x_labels
# This turns the Y axis labels by 90 degrees.
# Default it false, to turn on set to true.
attr_accessor :rotate_y_labels
# How many "steps" to use between displayed X axis labels,
# a step of one means display every label, a step of two results
# in every other label being displayed (label <gap> label <gap> label),
# a step of three results in every third label being displayed
# (label <gap> <gap> label <gap> <gap> label) and so on.
attr_accessor :step_x_labels
# Whether to (when taking "steps" between X axis labels) step from
# the first label (i.e. always include the first label) or step from
# the X axis origin (i.e. start with a gap if step_x_labels is greater
# than one).
attr_accessor :step_include_first_x_label
# Whether to show labels on the Y axis or not, defaults
# to true, set to false if you want to turn them off.
attr_accessor :show_y_labels
# Ensures only whole numbers are used as the scale divisions.
# Default it false, to turn on set to true. This has no effect if
# scale divisions are less than 1.
attr_accessor :scale_integers
# This defines the gap between markers on the Y axis,
# default is a 10th of the max_value, e.g. you will have
# 10 markers on the Y axis. NOTE: do not set this too
# low - you are limited to 999 markers, after that the
# graph won't generate.
attr_accessor :scale_divisions
# Whether to show the title under the X axis labels,
# default is false, set to true to show.
attr_accessor :show_x_title
# What the title under X axis should be, e.g. 'Months'.
attr_accessor :x_title
# Whether to show the title under the Y axis labels,
# default is false, set to true to show.
attr_accessor :show_y_title
# Aligns writing mode for Y axis label.
# Defaults to :bt (Bottom to Top).
# Change to :tb (Top to Bottom) to reverse.
attr_accessor :y_title_text_direction
# What the title under Y axis should be, e.g. 'Sales in thousands'.
attr_accessor :y_title
# Whether to show a title on the graph, defaults
# to false, set to true to show.
attr_accessor :show_graph_title
# What the title on the graph should be.
attr_accessor :graph_title
# Whether to show a subtitle on the graph, defaults
# to false, set to true to show.
attr_accessor :show_graph_subtitle
# What the subtitle on the graph should be.
attr_accessor :graph_subtitle
# Whether to show a key, defaults to false, set to
# true if you want to show it.
attr_accessor :key
# Where the key should be positioned, defaults to
# :right, set to :bottom if you want to move it.
attr_accessor :key_position
# Set the font size (in points) of the data point labels
attr_accessor :font_size
# Set the font size of the X axis labels
attr_accessor :x_label_font_size
# Set the font size of the X axis title
attr_accessor :x_title_font_size
# Set the font size of the Y axis labels
attr_accessor :y_label_font_size
# Set the font size of the Y axis title
attr_accessor :y_title_font_size
# Set the title font size
attr_accessor :title_font_size
# Set the subtitle font size
attr_accessor :subtitle_font_size
# Set the key font size
attr_accessor :key_font_size
# Show guidelines for the X axis
attr_accessor :show_x_guidelines
# Show guidelines for the Y axis
attr_accessor :show_y_guidelines
# Do not use CSS if set to true. Many SVG viewers do not support CSS, but
# not using CSS can result in larger SVGs as well as making it impossible to
# change colors after the chart is generated. Defaults to false.
attr_accessor :no_css
# Add popups for the data points on some graphs
attr_accessor :add_popups
protected
def sort( *arrys )
sort_multiple( arrys )
end
# Overwrite configuration options with supplied options. Used
# by subclasses.
def init_with config
config.each { |key, value|
self.send((key.to_s+"=").to_sym, value ) if respond_to? key.to_sym
}
end
attr_accessor :top_align, :top_font, :right_align, :right_font
KEY_BOX_SIZE = 12
# Override this (and call super) to change the margin to the left
# of the plot area. Results in @border_left being set.
def calculate_left_margin
@border_left = 7
# Check for Y labels
max_y_label_height_px = rotate_y_labels ?
y_label_font_size :
get_y_labels.max{|a,b|
a.to_s.length<=>b.to_s.length
}.to_s.length * y_label_font_size * 0.6
@border_left += max_y_label_height_px if show_y_labels
@border_left += max_y_label_height_px + 10 if stagger_y_labels
@border_left += y_title_font_size + 5 if show_y_title
end
# Calculates the width of the widest Y label. This will be the
# character height if the Y labels are rotated
def max_y_label_width_px
return font_size if rotate_y_labels
end
# Override this (and call super) to change the margin to the right
# of the plot area. Results in @border_right being set.
def calculate_right_margin
@border_right = 7
if key and key_position == :right
val = keys.max { |a,b| a.length <=> b.length }
@border_right += val.length * key_font_size * 0.6
@border_right += KEY_BOX_SIZE
@border_right += 10 # Some padding around the box
end
end
# Override this (and call super) to change the margin to the top
# of the plot area. Results in @border_top being set.
def calculate_top_margin
@border_top = 5
@border_top += title_font_size if show_graph_title
@border_top += 5
@border_top += subtitle_font_size if show_graph_subtitle
end
# Adds pop-up point information to a graph.
def add_popup( x, y, label )
txt_width = label.length * font_size * 0.6 + 10
tx = (x+txt_width > width ? x-5 : x+5)
t = @foreground.add_element( "text", {
"x" => tx.to_s,
"y" => (y - font_size).to_s,
"visibility" => "hidden",
})
t.attributes["style"] = "fill: #000; "+
(x+txt_width > width ? "text-anchor: end;" : "text-anchor: start;")
t.text = label.to_s
t.attributes["id"] = t.object_id.to_s
@foreground.add_element( "circle", {
"cx" => x.to_s,
"cy" => y.to_s,
"r" => "10",
"style" => "opacity: 0",
"onmouseover" =>
"document.getElementById(#{t.object_id}).setAttribute('visibility', 'visible' )",
"onmouseout" =>
"document.getElementById(#{t.object_id}).setAttribute('visibility', 'hidden' )",
})
end
# Override this (and call super) to change the margin to the bottom
# of the plot area. Results in @border_bottom being set.
def calculate_bottom_margin
@border_bottom = 7
if key and key_position == :bottom
@border_bottom += @data.size * (font_size + 5)
@border_bottom += 10
end
if show_x_labels
max_x_label_height_px = (not rotate_x_labels) ?
x_label_font_size :
get_x_labels.max{|a,b|
a.to_s.length<=>b.to_s.length
}.to_s.length * x_label_font_size * 0.6
@border_bottom += max_x_label_height_px
@border_bottom += max_x_label_height_px + 10 if stagger_x_labels
end
@border_bottom += x_title_font_size + 5 if show_x_title
end
# Draws the background, axis, and labels.
def draw_graph
@graph = @root.add_element( "g", {
"transform" => "translate( #@border_left #@border_top )"
})
# Background
@graph.add_element( "rect", {
"x" => "0",
"y" => "0",
"width" => @graph_width.to_s,
"height" => @graph_height.to_s,
"class" => "graphBackground"
})
# Axis
@graph.add_element( "path", {
"d" => "M 0 0 v#@graph_height",
"class" => "axis",
"id" => "xAxis"
})
@graph.add_element( "path", {
"d" => "M 0 #@graph_height h#@graph_width",
"class" => "axis",
"id" => "yAxis"
})
draw_x_labels
draw_y_labels
end
# Where in the X area the label is drawn
# Centered in the field, should be width/2. Start, 0.
def x_label_offset( width )
0
end
def make_datapoint_text( x, y, value, style="" )
if show_data_values
@foreground.add_element( "text", {
"x" => x.to_s,
"y" => y.to_s,
"class" => "dataPointLabel",
"style" => "#{style} stroke: #fff; stroke-width: 2;"
}).text = value.to_s
text = @foreground.add_element( "text", {
"x" => x.to_s,
"y" => y.to_s,
"class" => "dataPointLabel"
})
text.text = value.to_s
text.attributes["style"] = style if style.length > 0
end
end
# Draws the X axis labels
def draw_x_labels
stagger = x_label_font_size + 5
if show_x_labels
label_width = field_width
count = 0
for label in get_x_labels
if step_include_first_x_label == true then
step = count % step_x_labels
else
step = (count + 1) % step_x_labels
end
if step == 0 then
text = @graph.add_element( "text" )
text.attributes["class"] = "xAxisLabels"
text.text = label.to_s
x = count * label_width + x_label_offset( label_width )
y = @graph_height + x_label_font_size + 3
t = 0 - (font_size / 2)
if stagger_x_labels and count % 2 == 1
y += stagger
@graph.add_element( "path", {
"d" => "M#{x} #@graph_height v#{stagger}",
"class" => "staggerGuideLine"
})
end
text.attributes["x"] = x.to_s
text.attributes["y"] = y.to_s
if rotate_x_labels
text.attributes["transform"] =
"rotate( 90 #{x} #{y-x_label_font_size} )"+
" translate( 0 -#{x_label_font_size/4} )"
text.attributes["style"] = "text-anchor: start"
else
text.attributes["style"] = "text-anchor: middle"
end
end
draw_x_guidelines( label_width, count ) if show_x_guidelines
count += 1
end
end
end
# Where in the Y area the label is drawn
# Centered in the field, should be width/2. Start, 0.
def y_label_offset( height )
0
end
def field_width
(@graph_width.to_f - font_size*2*right_font) /
(get_x_labels.length - right_align)
end
def field_height
(@graph_height.to_f - font_size*2*top_font) /
(get_y_labels.length - top_align)
end
# Draws the Y axis labels
def draw_y_labels
stagger = y_label_font_size + 5
if show_y_labels
label_height = field_height
count = 0
y_offset = @graph_height + y_label_offset( label_height )
y_offset += font_size/1.2 unless rotate_y_labels
for label in get_y_labels
y = y_offset - (label_height * count)
x = rotate_y_labels ? 0 : -3
if stagger_y_labels and count % 2 == 1
x -= stagger
@graph.add_element( "path", {
"d" => "M#{x} #{y} h#{stagger}",
"class" => "staggerGuideLine"
})
end
text = @graph.add_element( "text", {
"x" => x.to_s,
"y" => y.to_s,
"class" => "yAxisLabels"
})
text.text = label.to_s
if rotate_y_labels
text.attributes["transform"] = "translate( -#{font_size} 0 ) "+
"rotate( 90 #{x} #{y} ) "
text.attributes["style"] = "text-anchor: middle"
else
text.attributes["y"] = (y - (y_label_font_size/2)).to_s
text.attributes["style"] = "text-anchor: end"
end
draw_y_guidelines( label_height, count ) if show_y_guidelines
count += 1
end
end
end
# Draws the X axis guidelines
def draw_x_guidelines( label_height, count )
if count != 0
@graph.add_element( "path", {
"d" => "M#{label_height*count} 0 v#@graph_height",
"class" => "guideLines"
})
end
end
# Draws the Y axis guidelines
def draw_y_guidelines( label_height, count )
if count != 0
@graph.add_element( "path", {
"d" => "M0 #{@graph_height-(label_height*count)} h#@graph_width",
"class" => "guideLines"
})
end
end
# Draws the graph title and subtitle
def draw_titles
if show_graph_title
@root.add_element( "text", {
"x" => (width / 2).to_s,
"y" => (title_font_size).to_s,
"class" => "mainTitle"
}).text = graph_title.to_s
end
if show_graph_subtitle
y_subtitle = show_graph_title ?
title_font_size + 10 :
subtitle_font_size
@root.add_element("text", {
"x" => (width / 2).to_s,
"y" => (y_subtitle).to_s,
"class" => "subTitle"
}).text = graph_subtitle.to_s
end
if show_x_title
y = @graph_height + @border_top + x_title_font_size
if show_x_labels
y += x_label_font_size + 5 if stagger_x_labels
y += x_label_font_size + 5
end
x = width / 2
@root.add_element("text", {
"x" => x.to_s,
"y" => y.to_s,
"class" => "xAxisTitle",
}).text = x_title.to_s
end
if show_y_title
x = y_title_font_size + (y_title_text_direction==:bt ? 3 : -3)
y = height / 2
text = @root.add_element("text", {
"x" => x.to_s,
"y" => y.to_s,
"class" => "yAxisTitle",
})
text.text = y_title.to_s
if y_title_text_direction == :bt
text.attributes["transform"] = "rotate( -90, #{x}, #{y} )"
else
text.attributes["transform"] = "rotate( 90, #{x}, #{y} )"
end
end
end
def keys
return @data.collect{ |d| d[:title] }
end
# Draws the legend on the graph
def draw_legend
if key
group = @root.add_element( "g" )
key_count = 0
for key_name in keys
y_offset = (KEY_BOX_SIZE * key_count) + (key_count * 5)
group.add_element( "rect", {
"x" => 0.to_s,
"y" => y_offset.to_s,
"width" => KEY_BOX_SIZE.to_s,
"height" => KEY_BOX_SIZE.to_s,
"class" => "key#{key_count+1}"
})
group.add_element( "text", {
"x" => (KEY_BOX_SIZE + 5).to_s,
"y" => (y_offset + KEY_BOX_SIZE).to_s,
"class" => "keyText"
}).text = key_name.to_s
key_count += 1
end
case key_position
when :right
x_offset = @graph_width + @border_left + 10
y_offset = @border_top + 20
when :bottom
x_offset = @border_left + 20
y_offset = @border_top + @graph_height + 5
if show_x_labels
max_x_label_height_px = (not rotate_x_labels) ?
x_label_font_size :
get_x_labels.max{|a,b|
a.to_s.length<=>b.to_s.length
}.to_s.length * x_label_font_size * 0.6
x_label_font_size
y_offset += max_x_label_height_px
y_offset += max_x_label_height_px + 5 if stagger_x_labels
end
y_offset += x_title_font_size + 5 if show_x_title
end
group.attributes["transform"] = "translate(#{x_offset} #{y_offset})"
end
end
private
def sort_multiple( arrys, lo=0, hi=arrys[0].length-1 )
if lo < hi
p = partition(arrys,lo,hi)
sort_multiple(arrys, lo, p-1)
sort_multiple(arrys, p+1, hi)
end
arrys
end
def partition( arrys, lo, hi )
p = arrys[0][lo]
l = lo
z = lo+1
while z <= hi
if arrys[0][z] < p
l += 1
arrys.each { |arry| arry[z], arry[l] = arry[l], arry[z] }
end
z += 1
end
arrys.each { |arry| arry[lo], arry[l] = arry[l], arry[lo] }
l
end
def style
if no_css
styles = parse_css
@root.elements.each("//*[@class]") { |el|
cl = el.attributes["class"]
style = styles[cl]
style += el.attributes["style"] if el.attributes["style"]
el.attributes["style"] = style
}
end
end
def parse_css
css = get_style
rv = {}
while css =~ /^(\.(\w+)(?:\s*,\s*\.\w+)*)\s*\{/m
names_orig = names = $1
css = $'
css =~ /([^}]+)\}/m
content = $1
css = $'
nms = []
while names =~ /^\s*,?\s*\.(\w+)/
nms << $1
names = $'
end
content = content.tr( "\n\t", " ")
for name in nms
current = rv[name]
current = current ? current+"; "+content : content
rv[name] = current.strip.squeeze(" ")
end
end
return rv
end
# Override and place code to add defs here
def add_defs defs
end
def start_svg
# Base document
@doc = Document.new
@doc << XMLDecl.new
@doc << DocType.new( %q{svg PUBLIC "-//W3C//DTD SVG 1.0//EN" } +
%q{"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"} )
if style_sheet && style_sheet != ''
@doc << Instruction.new( "xml-stylesheet",
%Q{href="#{style_sheet}" type="text/css"} )
end
@root = @doc.add_element( "svg", {
"width" => width.to_s,
"height" => height.to_s,
"viewBox" => "0 0 #{width} #{height}",
"xmlns" => "http://www.w3.org/2000/svg",
"xmlns:xlink" => "http://www.w3.org/1999/xlink",
"xmlns:a3" => "http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/",
"a3:scriptImplementation" => "Adobe"
})
@root << Comment.new( " "+"\\"*66 )
@root << Comment.new( " Created with SVG::Graph " )
@root << Comment.new( " SVG::Graph by Sean E. Russell " )
@root << Comment.new( " Losely based on SVG::TT::Graph for Perl by"+
" Leo Lapworth & Stephan Morgan " )
@root << Comment.new( " "+"/"*66 )
defs = @root.add_element( "defs" )
add_defs defs
if not(style_sheet && style_sheet != '') and !no_css
@root << Comment.new(" include default stylesheet if none specified ")
style = defs.add_element( "style", {"type"=>"text/css"} )
style << CData.new( get_style )
end
@root << Comment.new( "SVG Background" )
@root.add_element( "rect", {
"width" => width.to_s,
"height" => height.to_s,
"x" => "0",
"y" => "0",
"class" => "svgBackground"
})
end
def calculate_graph_dimensions
calculate_left_margin
calculate_right_margin
calculate_bottom_margin
calculate_top_margin
@graph_width = width - @border_left - @border_right
@graph_height = height - @border_top - @border_bottom
end
def get_style
return <<EOL
/* Copy from here for external style sheet */
.svgBackground{
fill:#ffffff;
}
.graphBackground{
fill:#f0f0f0;
}
/* graphs titles */
.mainTitle{
text-anchor: middle;
fill: #000000;
font-size: #{title_font_size}px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
.subTitle{
text-anchor: middle;
fill: #999999;
font-size: #{subtitle_font_size}px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
.axis{
stroke: #000000;
stroke-width: 1px;
}
.guideLines{
stroke: #666666;
stroke-width: 1px;
stroke-dasharray: 5 5;
}
.xAxisLabels{
text-anchor: middle;
fill: #000000;
font-size: #{x_label_font_size}px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
.yAxisLabels{
text-anchor: end;
fill: #000000;
font-size: #{y_label_font_size}px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
.xAxisTitle{
text-anchor: middle;
fill: #ff0000;
font-size: #{x_title_font_size}px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
.yAxisTitle{
fill: #ff0000;
text-anchor: middle;
font-size: #{y_title_font_size}px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
.dataPointLabel{
fill: #000000;
text-anchor:middle;
font-size: 10px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
.staggerGuideLine{
fill: none;
stroke: #000000;
stroke-width: 0.5px;
}
#{get_css}
.keyText{
fill: #000000;
text-anchor:start;
font-size: #{key_font_size}px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
/* End copy for external style sheet */
EOL
end
end
end
end

444
lib/SVG/Graph/Line.rb Normal file
View file

@ -0,0 +1,444 @@
require 'SVG/Graph/Graph'
module SVG
module Graph
# === Create presentation quality SVG line graphs easily
#
# = Synopsis
#
# require 'SVG/Graph/Line'
#
# fields = %w(Jan Feb Mar);
# data_sales_02 = [12, 45, 21]
# data_sales_03 = [15, 30, 40]
#
# graph = SVG::Graph::Line.new({
# :height => 500,
# :width => 300,
# :fields => fields,
# })
#
# graph.add_data({
# :data => data_sales_02,
# :title => 'Sales 2002',
# })
#
# graph.add_data({
# :data => data_sales_03,
# :title => 'Sales 2003',
# })
#
# print "Content-type: image/svg+xml\r\n\r\n";
# print graph.burn();
#
# = Description
#
# This object aims to allow you to easily create high quality
# SVG line graphs. You can either use the default style sheet
# or supply your own. Either way there are many options which can
# be configured to give you control over how the graph is
# generated - with or without a key, data elements at each point,
# title, subtitle etc.
#
# = Examples
#
# http://www.germane-software/repositories/public/SVG/test/single.rb
#
# = Notes
#
# The default stylesheet handles upto 10 data sets, if you
# use more you must create your own stylesheet and add the
# additional settings for the extra data sets. You will know
# if you go over 10 data sets as they will have no style and
# be in black.
#
# = See also
#
# * SVG::Graph::Graph
# * SVG::Graph::BarHorizontal
# * SVG::Graph::Bar
# * SVG::Graph::Pie
# * SVG::Graph::Plot
# * SVG::Graph::TimeSeries
#
# == Author
#
# Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
#
# Copyright 2004 Sean E. Russell
# This software is available under the Ruby license[LICENSE.txt]
#
class Line < SVG::Graph::Graph
# Show a small circle on the graph where the line
# goes from one point to the next.
attr_accessor :show_data_points
# Accumulates each data set. (i.e. Each point increased by sum of
# all previous series at same point). Default is 0, set to '1' to show.
attr_accessor :stacked
# Fill in the area under the plot if true
attr_accessor :area_fill
# The constructor takes a hash reference, fields (the names for each
# field on the X axis) MUST be set, all other values are defaulted to
# those shown above - with the exception of style_sheet which defaults
# to using the internal style sheet.
def initialize config
raise "fields was not supplied or is empty" unless config[:fields] &&
config[:fields].kind_of?(Array) &&
config[:fields].length > 0
super
end
# In addition to the defaults set in Graph::initialize, sets
# [show_data_points] true
# [show_data_values] true
# [stacked] false
# [area_fill] false
def set_defaults
init_with(
:show_data_points => true,
:show_data_values => true,
:stacked => false,
:area_fill => false
)
self.top_align = self.top_font = self.right_align = self.right_font = 1
end
protected
def max_value
max = 0
if (stacked == true) then
sums = Array.new(@config[:fields].length).fill(0)
@data.each do |data|
sums.each_index do |i|
sums[i] += data[:data][i].to_f
end
end
max = sums.max
else
max = @data.collect{|x| x[:data].max}.max
end
return max
end
def min_value
min = 0
if (min_scale_value.nil? == false) then
min = min_scale_value
elsif (stacked == true) then
min = @data[-1][:data].min
else
min = @data.collect{|x| x[:data].min}.min
end
return min
end
def get_x_labels
@config[:fields]
end
def calculate_left_margin
super
label_left = @config[:fields][0].length / 2 * font_size * 0.6
@border_left = label_left if label_left > @border_left
end
def get_y_labels
maxvalue = max_value
minvalue = min_value
range = maxvalue - minvalue
top_pad = range == 0 ? 10 : range / 20.0
scale_range = (maxvalue + top_pad) - minvalue
scale_division = scale_divisions || (scale_range / 10.0)
if scale_integers
scale_division = scale_division < 1 ? 1 : scale_division.round
end
rv = []
maxvalue = maxvalue%scale_division == 0 ?
maxvalue : maxvalue + scale_division
minvalue.step( maxvalue, scale_division ) {|v| rv << v}
return rv
end
def calc_coords(field, value, width = field_width, height = field_height)
coords = {:x => 0, :y => 0}
coords[:x] = width * field
coords[:y] = @graph_height - value * height
return coords
end
def draw_data
minvalue = min_value
fieldheight = (@graph_height.to_f - font_size*2*top_font) /
(get_y_labels.max - get_y_labels.min)
fieldwidth = field_width
line = @data.length
prev_sum = Array.new(@config[:fields].length).fill(0)
cum_sum = Array.new(@config[:fields].length).fill(-minvalue)
for data in @data.reverse
lpath = ""
apath = ""
if not stacked then cum_sum.fill(-minvalue) end
data[:data].each_index do |i|
cum_sum[i] += data[:data][i]
c = calc_coords(i, cum_sum[i], fieldwidth, fieldheight)
lpath << "#{c[:x]} #{c[:y]} "
end
if area_fill
if stacked then
(prev_sum.length - 1).downto 0 do |i|
c = calc_coords(i, prev_sum[i], fieldwidth, fieldheight)
apath << "#{c[:x]} #{c[:y]} "
end
c = calc_coords(0, prev_sum[0], fieldwidth, fieldheight)
else
apath = "V#@graph_height"
c = calc_coords(0, 0, fieldwidth, fieldheight)
end
@graph.add_element("path", {
"d" => "M#{c[:x]} #{c[:y]} L" + lpath + apath + "Z",
"class" => "fill#{line}"
})
end
@graph.add_element("path", {
"d" => "M0 #@graph_height L" + lpath,
"class" => "line#{line}"
})
if show_data_points || show_data_values
cum_sum.each_index do |i|
if show_data_points
@graph.add_element( "circle", {
"cx" => (fieldwidth * i).to_s,
"cy" => (@graph_height - cum_sum[i] * fieldheight).to_s,
"r" => "2.5",
"class" => "dataPoint#{line}"
})
end
make_datapoint_text(
fieldwidth * i,
@graph_height - cum_sum[i] * fieldheight - 6,
cum_sum[i] + minvalue
)
end
end
prev_sum = cum_sum.dup
line -= 1
end
end
def get_css
return <<EOL
/* default line styles */
.line1{
fill: none;
stroke: #ff0000;
stroke-width: 1px;
}
.line2{
fill: none;
stroke: #0000ff;
stroke-width: 1px;
}
.line3{
fill: none;
stroke: #00ff00;
stroke-width: 1px;
}
.line4{
fill: none;
stroke: #ffcc00;
stroke-width: 1px;
}
.line5{
fill: none;
stroke: #00ccff;
stroke-width: 1px;
}
.line6{
fill: none;
stroke: #ff00ff;
stroke-width: 1px;
}
.line7{
fill: none;
stroke: #00ffff;
stroke-width: 1px;
}
.line8{
fill: none;
stroke: #ffff00;
stroke-width: 1px;
}
.line9{
fill: none;
stroke: #ccc6666;
stroke-width: 1px;
}
.line10{
fill: none;
stroke: #663399;
stroke-width: 1px;
}
.line11{
fill: none;
stroke: #339900;
stroke-width: 1px;
}
.line12{
fill: none;
stroke: #9966FF;
stroke-width: 1px;
}
/* default fill styles */
.fill1{
fill: #cc0000;
fill-opacity: 0.2;
stroke: none;
}
.fill2{
fill: #0000cc;
fill-opacity: 0.2;
stroke: none;
}
.fill3{
fill: #00cc00;
fill-opacity: 0.2;
stroke: none;
}
.fill4{
fill: #ffcc00;
fill-opacity: 0.2;
stroke: none;
}
.fill5{
fill: #00ccff;
fill-opacity: 0.2;
stroke: none;
}
.fill6{
fill: #ff00ff;
fill-opacity: 0.2;
stroke: none;
}
.fill7{
fill: #00ffff;
fill-opacity: 0.2;
stroke: none;
}
.fill8{
fill: #ffff00;
fill-opacity: 0.2;
stroke: none;
}
.fill9{
fill: #cc6666;
fill-opacity: 0.2;
stroke: none;
}
.fill10{
fill: #663399;
fill-opacity: 0.2;
stroke: none;
}
.fill11{
fill: #339900;
fill-opacity: 0.2;
stroke: none;
}
.fill12{
fill: #9966FF;
fill-opacity: 0.2;
stroke: none;
}
/* default line styles */
.key1,.dataPoint1{
fill: #ff0000;
stroke: none;
stroke-width: 1px;
}
.key2,.dataPoint2{
fill: #0000ff;
stroke: none;
stroke-width: 1px;
}
.key3,.dataPoint3{
fill: #00ff00;
stroke: none;
stroke-width: 1px;
}
.key4,.dataPoint4{
fill: #ffcc00;
stroke: none;
stroke-width: 1px;
}
.key5,.dataPoint5{
fill: #00ccff;
stroke: none;
stroke-width: 1px;
}
.key6,.dataPoint6{
fill: #ff00ff;
stroke: none;
stroke-width: 1px;
}
.key7,.dataPoint7{
fill: #00ffff;
stroke: none;
stroke-width: 1px;
}
.key8,.dataPoint8{
fill: #ffff00;
stroke: none;
stroke-width: 1px;
}
.key9,.dataPoint9{
fill: #cc6666;
stroke: none;
stroke-width: 1px;
}
.key10,.dataPoint10{
fill: #663399;
stroke: none;
stroke-width: 1px;
}
.key11,.dataPoint11{
fill: #339900;
stroke: none;
stroke-width: 1px;
}
.key12,.dataPoint12{
fill: #9966FF;
stroke: none;
stroke-width: 1px;
}
EOL
end
end
end
end

395
lib/SVG/Graph/Pie.rb Normal file
View file

@ -0,0 +1,395 @@
require 'SVG/Graph/Graph'
module SVG
module Graph
# === Create presentation quality SVG pie graphs easily
#
# == Synopsis
#
# require 'SVG/Graph/Pie'
#
# fields = %w(Jan Feb Mar)
# data_sales_02 = [12, 45, 21]
#
# graph = SVG::Graph::Pie.new({
# :height => 500,
# :width => 300,
# :fields => fields,
# })
#
# graph.add_data({
# :data => data_sales_02,
# :title => 'Sales 2002',
# })
#
# print "Content-type: image/svg+xml\r\n\r\n"
# print graph.burn();
#
# == Description
#
# This object aims to allow you to easily create high quality
# SVG pie graphs. You can either use the default style sheet
# or supply your own. Either way there are many options which can
# be configured to give you control over how the graph is
# generated - with or without a key, display percent on pie chart,
# title, subtitle etc.
#
# = Examples
#
# http://www.germane-software/repositories/public/SVG/test/single.rb
#
# == See also
#
# * SVG::Graph::Graph
# * SVG::Graph::BarHorizontal
# * SVG::Graph::Bar
# * SVG::Graph::Line
# * SVG::Graph::Plot
# * SVG::Graph::TimeSeries
#
# == Author
#
# Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
#
# Copyright 2004 Sean E. Russell
# This software is available under the Ruby license[LICENSE.txt]
#
class Pie < Graph
# Defaults are those set by Graph::initialize, and
# [show_shadow] true
# [shadow_offset] 10
# [show_data_labels] false
# [show_actual_values] false
# [show_percent] true
# [show_key_data_labels] true
# [show_key_actual_values] true
# [show_key_percent] false
# [expanded] false
# [expand_greatest] false
# [expand_gap] 10
# [show_x_labels] false
# [show_y_labels] false
# [datapoint_font_size] 12
def set_defaults
init_with(
:show_shadow => true,
:shadow_offset => 10,
:show_data_labels => false,
:show_actual_values => false,
:show_percent => true,
:show_key_data_labels => true,
:show_key_actual_values => true,
:show_key_percent => false,
:expanded => false,
:expand_greatest => false,
:expand_gap => 10,
:show_x_labels => false,
:show_y_labels => false,
:datapoint_font_size => 12
)
@data = []
end
# Adds a data set to the graph.
#
# graph.add_data( { :data => [1,2,3,4] } )
#
# Note that the :title is not necessary. If multiple
# data sets are added to the graph, the pie chart will
# display the +sums+ of the data. EG:
#
# graph.add_data( { :data => [1,2,3,4] } )
# graph.add_data( { :data => [2,3,5,9] } )
#
# is the same as:
#
# graph.add_data( { :data => [3,5,8,13] } )
def add_data arg
arg[:data].each_index {|idx|
@data[idx] = 0 unless @data[idx]
@data[idx] += arg[:data][idx]
}
end
# If true, displays a drop shadow for the chart
attr_accessor :show_shadow
# Sets the offset of the shadow from the pie chart
attr_accessor :shadow_offset
# If true, display the data labels on the chart
attr_accessor :show_data_labels
# If true, display the actual field values in the data labels
attr_accessor :show_actual_values
# If true, display the percentage value of each pie wedge in the data
# labels
attr_accessor :show_percent
# If true, display the labels in the key
attr_accessor :show_key_data_labels
# If true, display the actual value of the field in the key
attr_accessor :show_key_actual_values
# If true, display the percentage value of the wedges in the key
attr_accessor :show_key_percent
# If true, "explode" the pie (put space between the wedges)
attr_accessor :expanded
# If true, expand the largest pie wedge
attr_accessor :expand_greatest
# The amount of space between expanded wedges
attr_accessor :expand_gap
# The font size of the data point labels
attr_accessor :datapoint_font_size
protected
def add_defs defs
gradient = defs.add_element( "filter", {
"id"=>"dropshadow",
"width" => "1.2",
"height" => "1.2",
} )
gradient.add_element( "feGaussianBlur", {
"stdDeviation" => "4",
"result" => "blur"
})
end
# We don't need the graph
def draw_graph
end
def get_y_labels
[""]
end
def get_x_labels
[""]
end
def keys
total = 0
max_value = 0
@data.each {|x| total += x }
percent_scale = 100.0 / total
count = -1
a = @config[:fields].collect{ |x|
count += 1
v = @data[count]
perc = show_key_percent ? " "+(v * percent_scale).round.to_s+"%" : ""
x + " [" + v.to_s + "]" + perc
}
end
RADIANS = Math::PI/180
def draw_data
@graph = @root.add_element( "g" )
background = @graph.add_element("g")
midground = @graph.add_element("g")
diameter = @graph_height > @graph_width ? @graph_width : @graph_height
diameter -= expand_gap if expanded or expand_greatest
diameter -= datapoint_font_size if show_data_labels
diameter -= 10 if show_shadow
radius = diameter / 2.0
xoff = (width - diameter) / 2
yoff = (height - @border_bottom - diameter)
yoff -= 10 if show_shadow
@graph.attributes['transform'] = "translate( #{xoff} #{yoff} )"
wedge_text_pad = 5
wedge_text_pad = 20 if show_percent and show_data_labels
total = 0
max_value = 0
@data.each {|x|
max_value = max_value < x ? x : max_value
total += x
}
percent_scale = 100.0 / total
prev_percent = 0
rad_mult = 3.6 * RADIANS
@config[:fields].each_index { |count|
value = @data[count]
percent = percent_scale * value
radians = prev_percent * rad_mult
x_start = radius+(Math.sin(radians) * radius)
y_start = radius-(Math.cos(radians) * radius)
radians = (prev_percent+percent) * rad_mult
x_end = radius+(Math.sin(radians) * radius)
x_end -= 0.00001 if @data.length == 1
y_end = radius-(Math.cos(radians) * radius)
path = "M#{radius},#{radius} L#{x_start},#{y_start} "+
"A#{radius},#{radius} "+
"0, #{percent >= 50 ? '1' : '0'},1, "+
"#{x_end} #{y_end} Z"
wedge = @foreground.add_element( "path", {
"d" => path,
"class" => "fill#{count+1}"
})
translate = nil
tx = 0
ty = 0
half_percent = prev_percent + percent / 2
radians = half_percent * rad_mult
if show_shadow
shadow = background.add_element( "path", {
"d" => path,
"filter" => "url(#dropshadow)",
"style" => "fill: #ccc; stroke: none;"
})
clear = midground.add_element( "path", {
"d" => path,
"style" => "fill: #fff; stroke: none;"
})
end
if expanded or (expand_greatest && value == max_value)
tx = (Math.sin(radians) * expand_gap)
ty = -(Math.cos(radians) * expand_gap)
translate = "translate( #{tx} #{ty} )"
wedge.attributes["transform"] = translate
clear.attributes["transform"] = translate if clear
end
if show_shadow
shadow.attributes["transform"] =
"translate( #{tx+shadow_offset} #{ty+shadow_offset} )"
end
if show_data_labels and value != 0
label = ""
label += @config[:fields][count] if show_key_data_labels
label += " ["+value.to_s+"]" if show_actual_values
label += " "+percent.round.to_s+"%" if show_percent
msr = Math.sin(radians)
mcr = Math.cos(radians)
tx = radius + (msr * radius)
ty = radius -(mcr * radius)
if expanded or (expand_greatest && value == max_value)
tx += (msr * expand_gap)
ty -= (mcr * expand_gap)
end
@foreground.add_element( "text", {
"x" => tx.to_s,
"y" => ty.to_s,
"class" => "dataPointLabel",
"style" => "stroke: #fff; stroke-width: 2;"
}).text = label.to_s
@foreground.add_element( "text", {
"x" => tx.to_s,
"y" => ty.to_s,
"class" => "dataPointLabel",
}).text = label.to_s
end
prev_percent += percent
}
end
def round val, to
up = 10**to.to_f
(val * up).to_i / up
end
def get_css
return <<EOL
.dataPointLabel{
fill: #000000;
text-anchor:middle;
font-size: #{datapoint_font_size}px;
font-family: "Arial", sans-serif;
font-weight: normal;
}
/* key - MUST match fill styles */
.key1,.fill1{
fill: #ff0000;
fill-opacity: 0.7;
stroke: none;
stroke-width: 1px;
}
.key2,.fill2{
fill: #0000ff;
fill-opacity: 0.7;
stroke: none;
stroke-width: 1px;
}
.key3,.fill3{
fill-opacity: 0.7;
fill: #00ff00;
stroke: none;
stroke-width: 1px;
}
.key4,.fill4{
fill-opacity: 0.7;
fill: #ffcc00;
stroke: none;
stroke-width: 1px;
}
.key5,.fill5{
fill-opacity: 0.7;
fill: #00ccff;
stroke: none;
stroke-width: 1px;
}
.key6,.fill6{
fill-opacity: 0.7;
fill: #ff00ff;
stroke: none;
stroke-width: 1px;
}
.key7,.fill7{
fill-opacity: 0.7;
fill: #00ff99;
stroke: none;
stroke-width: 1px;
}
.key8,.fill8{
fill-opacity: 0.7;
fill: #ffff00;
stroke: none;
stroke-width: 1px;
}
.key9,.fill9{
fill-opacity: 0.7;
fill: #cc6666;
stroke: none;
stroke-width: 1px;
}
.key10,.fill10{
fill-opacity: 0.7;
fill: #663399;
stroke: none;
stroke-width: 1px;
}
.key11,.fill11{
fill-opacity: 0.7;
fill: #339900;
stroke: none;
stroke-width: 1px;
}
.key12,.fill12{
fill-opacity: 0.7;
fill: #9966FF;
stroke: none;
stroke-width: 1px;
}
EOL
end
end
end
end

500
lib/SVG/Graph/Plot.rb Normal file
View file

@ -0,0 +1,500 @@
require 'SVG/Graph/Graph'
module SVG
module Graph
# === For creating SVG plots of scalar data
#
# = Synopsis
#
# require 'SVG/Graph/Plot'
#
# # Data sets are x,y pairs
# # Note that multiple data sets can differ in length, and that the
# # data in the datasets needn't be in order; they will be ordered
# # by the plot along the X-axis.
# projection = [
# 6, 11, 0, 5, 18, 7, 1, 11, 13, 9, 1, 2, 19, 0, 3, 13,
# 7, 9
# ]
# actual = [
# 0, 18, 8, 15, 9, 4, 18, 14, 10, 2, 11, 6, 14, 12,
# 15, 6, 4, 17, 2, 12
# ]
#
# graph = SVG::Graph::Plot.new({
# :height => 500,
# :width => 300,
# :key => true,
# :scale_x_integers => true,
# :scale_y_integerrs => true,
# })
#
# graph.add_data({
# :data => projection
# :title => 'Projected',
# })
#
# graph.add_data({
# :data => actual,
# :title => 'Actual',
# })
#
# print graph.burn()
#
# = Description
#
# Produces a graph of scalar data.
#
# This object aims to allow you to easily create high quality
# SVG[http://www.w3c.org/tr/svg] scalar plots. You can either use the
# default style sheet or supply your own. Either way there are many options
# which can be configured to give you control over how the graph is
# generated - with or without a key, data elements at each point, title,
# subtitle etc.
#
# = Examples
#
# http://www.germane-software/repositories/public/SVG/test/plot.rb
#
# = Notes
#
# The default stylesheet handles upto 10 data sets, if you
# use more you must create your own stylesheet and add the
# additional settings for the extra data sets. You will know
# if you go over 10 data sets as they will have no style and
# be in black.
#
# Unlike the other types of charts, data sets must contain x,y pairs:
#
# [ 1, 2 ] # A data set with 1 point: (1,2)
# [ 1,2, 5,6] # A data set with 2 points: (1,2) and (5,6)
#
# = See also
#
# * SVG::Graph::Graph
# * SVG::Graph::BarHorizontal
# * SVG::Graph::Bar
# * SVG::Graph::Line
# * SVG::Graph::Pie
# * SVG::Graph::TimeSeries
#
# == Author
#
# Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
#
# Copyright 2004 Sean E. Russell
# This software is available under the Ruby license[LICENSE.txt]
#
class Plot < Graph
# In addition to the defaults set by Graph::initialize, sets
# [show_data_values] true
# [show_data_points] true
# [area_fill] false
# [stacked] false
def set_defaults
init_with(
:show_data_values => true,
:show_data_points => true,
:area_fill => false,
:stacked => false
)
self.top_align = self.right_align = self.top_font = self.right_font = 1
end
# Determines the scaling for the X axis divisions.
#
# graph.scale_x_divisions = 2
#
# would cause the graph to attempt to generate labels stepped by 2; EG:
# 0,2,4,6,8...
attr_accessor :scale_x_divisions
# Determines the scaling for the Y axis divisions.
#
# graph.scale_y_divisions = 0.5
#
# would cause the graph to attempt to generate labels stepped by 0.5; EG:
# 0, 0.5, 1, 1.5, 2, ...
attr_accessor :scale_y_divisions
# Make the X axis labels integers
attr_accessor :scale_x_integers
# Make the Y axis labels integers
attr_accessor :scale_y_integers
# Fill the area under the line
attr_accessor :area_fill
# Show a small circle on the graph where the line
# goes from one point to the next.
attr_accessor :show_data_points
# Set the minimum value of the X axis
attr_accessor :min_x_value
# Set the minimum value of the Y axis
attr_accessor :min_y_value
# Adds data to the plot. The data must be in X,Y pairs; EG
# [ 1, 2 ] # A data set with 1 point: (1,2)
# [ 1,2, 5,6] # A data set with 2 points: (1,2) and (5,6)
def add_data data
@data = [] unless @data
raise "No data provided by #{conf.inspect}" unless data[:data] and
data[:data].kind_of? Array
raise "Data supplied must be x,y pairs! "+
"The data provided contained an odd set of "+
"data points" unless data[:data].length % 2 == 0
return if data[:data].length == 0
x = []
y = []
data[:data].each_index {|i|
(i%2 == 0 ? x : y) << data[:data][i]
}
sort( x, y )
data[:data] = [x,y]
@data << data
end
protected
def keys
@data.collect{ |x| x[:title] }
end
def calculate_left_margin
super
label_left = get_x_labels[0].to_s.length / 2 * font_size * 0.6
@border_left = label_left if label_left > @border_left
end
def calculate_right_margin
super
label_right = get_x_labels[-1].to_s.length / 2 * font_size * 0.6
@border_right = label_right if label_right > @border_right
end
X = 0
Y = 1
def x_range
max_value = @data.collect{|x| x[:data][X][-1] }.max
min_value = @data.collect{|x| x[:data][X][0] }.min
min_value = min_value<min_x_value ? min_value : min_x_value if min_x_value
range = max_value - min_value
right_pad = range == 0 ? 10 : range / 20.0
scale_range = (max_value + right_pad) - min_value
scale_division = scale_x_divisions || (scale_range / 10.0)
if scale_x_integers
scale_division = scale_division < 1 ? 1 : scale_division.round
end
[min_value, max_value, scale_division]
end
def get_x_values
min_value, max_value, scale_division = x_range
rv = []
min_value.step( max_value, scale_division ) {|v| rv << v}
return rv
end
alias :get_x_labels :get_x_values
def field_width
values = get_x_values
max = @data.collect{|x| x[:data][X][-1]}.max
dx = (max - values[-1]).to_f / (values[-1] - values[-2])
(@graph_width.to_f - font_size*2*right_font) /
(values.length + dx - right_align)
end
def y_range
max_value = @data.collect{|x| x[:data][Y].max }.max
min_value = @data.collect{|x| x[:data][Y].min }.min
min_value = min_value<min_y_value ? min_value : min_y_value if min_y_value
range = max_value - min_value
top_pad = range == 0 ? 10 : range / 20.0
scale_range = (max_value + top_pad) - min_value
scale_division = scale_y_divisions || (scale_range / 10.0)
if scale_y_integers
scale_division = scale_division < 1 ? 1 : scale_division.round
end
return [min_value, max_value, scale_division]
end
def get_y_values
min_value, max_value, scale_division = y_range
rv = []
min_value.step( max_value, scale_division ) {|v| rv << v}
return rv
end
alias :get_y_labels :get_y_values
def field_height
values = get_y_values
max = @data.collect{|x| x[:data][Y].max }.max
if values.length == 1
dx = values[-1]
else
dx = (max - values[-1]).to_f / (values[-1] - values[-2])
end
(@graph_height.to_f - font_size*2*top_font) /
(values.length + dx - top_align)
end
def draw_data
line = 1
x_min, x_max, x_div = x_range
y_min, y_max, y_div = y_range
x_step = (@graph_width.to_f - font_size*2) / (x_max-x_min)
y_step = (@graph_height.to_f - font_size*2) / (y_max-y_min)
for data in @data
x_points = data[:data][X]
y_points = data[:data][Y]
lpath = "L"
x_start = 0
y_start = 0
x_points.each_index { |idx|
x = (x_points[idx] - x_min) * x_step
y = @graph_height - (y_points[idx] - y_min) * y_step
x_start, y_start = x,y if idx == 0
lpath << "#{x} #{y} "
}
if area_fill
@graph.add_element( "path", {
"d" => "M#{x_start} #@graph_height #{lpath} V#@graph_height Z",
"class" => "fill#{line}"
})
end
@graph.add_element( "path", {
"d" => "M#{x_start} #{y_start} #{lpath}",
"class" => "line#{line}"
})
if show_data_points || show_data_values
x_points.each_index { |idx|
x = (x_points[idx] - x_min) * x_step
y = @graph_height - (y_points[idx] - y_min) * y_step
if show_data_points
@graph.add_element( "circle", {
"cx" => x.to_s,
"cy" => y.to_s,
"r" => "2.5",
"class" => "dataPoint#{line}"
})
add_popup(x, y, format( x_points[idx], y_points[idx] )) if add_popups
end
make_datapoint_text( x, y-6, y_points[idx] ) if show_data_values
}
end
line += 1
end
end
def format x, y
"(#{(x * 100).to_i / 100}, #{(y * 100).to_i / 100})"
end
def get_css
return <<EOL
/* default line styles */
.line1{
fill: none;
stroke: #ff0000;
stroke-width: 1px;
}
.line2{
fill: none;
stroke: #0000ff;
stroke-width: 1px;
}
.line3{
fill: none;
stroke: #00ff00;
stroke-width: 1px;
}
.line4{
fill: none;
stroke: #ffcc00;
stroke-width: 1px;
}
.line5{
fill: none;
stroke: #00ccff;
stroke-width: 1px;
}
.line6{
fill: none;
stroke: #ff00ff;
stroke-width: 1px;
}
.line7{
fill: none;
stroke: #00ffff;
stroke-width: 1px;
}
.line8{
fill: none;
stroke: #ffff00;
stroke-width: 1px;
}
.line9{
fill: none;
stroke: #ccc6666;
stroke-width: 1px;
}
.line10{
fill: none;
stroke: #663399;
stroke-width: 1px;
}
.line11{
fill: none;
stroke: #339900;
stroke-width: 1px;
}
.line12{
fill: none;
stroke: #9966FF;
stroke-width: 1px;
}
/* default fill styles */
.fill1{
fill: #cc0000;
fill-opacity: 0.2;
stroke: none;
}
.fill2{
fill: #0000cc;
fill-opacity: 0.2;
stroke: none;
}
.fill3{
fill: #00cc00;
fill-opacity: 0.2;
stroke: none;
}
.fill4{
fill: #ffcc00;
fill-opacity: 0.2;
stroke: none;
}
.fill5{
fill: #00ccff;
fill-opacity: 0.2;
stroke: none;
}
.fill6{
fill: #ff00ff;
fill-opacity: 0.2;
stroke: none;
}
.fill7{
fill: #00ffff;
fill-opacity: 0.2;
stroke: none;
}
.fill8{
fill: #ffff00;
fill-opacity: 0.2;
stroke: none;
}
.fill9{
fill: #cc6666;
fill-opacity: 0.2;
stroke: none;
}
.fill10{
fill: #663399;
fill-opacity: 0.2;
stroke: none;
}
.fill11{
fill: #339900;
fill-opacity: 0.2;
stroke: none;
}
.fill12{
fill: #9966FF;
fill-opacity: 0.2;
stroke: none;
}
/* default line styles */
.key1,.dataPoint1{
fill: #ff0000;
stroke: none;
stroke-width: 1px;
}
.key2,.dataPoint2{
fill: #0000ff;
stroke: none;
stroke-width: 1px;
}
.key3,.dataPoint3{
fill: #00ff00;
stroke: none;
stroke-width: 1px;
}
.key4,.dataPoint4{
fill: #ffcc00;
stroke: none;
stroke-width: 1px;
}
.key5,.dataPoint5{
fill: #00ccff;
stroke: none;
stroke-width: 1px;
}
.key6,.dataPoint6{
fill: #ff00ff;
stroke: none;
stroke-width: 1px;
}
.key7,.dataPoint7{
fill: #00ffff;
stroke: none;
stroke-width: 1px;
}
.key8,.dataPoint8{
fill: #ffff00;
stroke: none;
stroke-width: 1px;
}
.key9,.dataPoint9{
fill: #cc6666;
stroke: none;
stroke-width: 1px;
}
.key10,.dataPoint10{
fill: #663399;
stroke: none;
stroke-width: 1px;
}
.key11,.dataPoint11{
fill: #339900;
stroke: none;
stroke-width: 1px;
}
.key12,.dataPoint12{
fill: #9966FF;
stroke: none;
stroke-width: 1px;
}
EOL
end
end
end
end

373
lib/SVG/Graph/Schedule.rb Normal file
View file

@ -0,0 +1,373 @@
require 'SVG/Graph/Plot'
require 'parsedate'
module SVG
module Graph
# === For creating SVG plots of scalar temporal data
#
# = Synopsis
#
# require 'SVG/Graph/Schedule'
#
# # Data sets are label, start, end tripples.
# data1 = [
# "Housesitting", "6/17/04", "6/19/04",
# "Summer Session", "6/15/04", "8/15/04",
# ]
#
# graph = SVG::Graph::Schedule.new( {
# :width => 640,
# :height => 480,
# :graph_title => title,
# :show_graph_title => true,
# :no_css => true,
# :scale_x_integers => true,
# :scale_y_integers => true,
# :min_x_value => 0,
# :min_y_value => 0,
# :show_data_labels => true,
# :show_x_guidelines => true,
# :show_x_title => true,
# :x_title => "Time",
# :stagger_x_labels => true,
# :stagger_y_labels => true,
# :x_label_format => "%m/%d/%y",
# })
#
# graph.add_data({
# :data => data1,
# :title => 'Data',
# })
#
# print graph.burn()
#
# = Description
#
# Produces a graph of temporal scalar data.
#
# = Examples
#
# http://www.germane-software/repositories/public/SVG/test/schedule.rb
#
# = Notes
#
# The default stylesheet handles up to 10 data sets, if you
# use more you must create your own stylesheet and add the
# additional settings for the extra data sets. You will know
# if you go over 10 data sets as they will have no style and
# be in black.
#
# Note that multiple data sets within the same chart can differ in
# length, and that the data in the datasets needn't be in order;
# they will be ordered by the plot along the X-axis.
#
# The dates must be parseable by ParseDate, but otherwise can be
# any order of magnitude (seconds within the hour, or years)
#
# = See also
#
# * SVG::Graph::Graph
# * SVG::Graph::BarHorizontal
# * SVG::Graph::Bar
# * SVG::Graph::Line
# * SVG::Graph::Pie
# * SVG::Graph::Plot
# * SVG::Graph::TimeSeries
#
# == Author
#
# Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
#
# Copyright 2004 Sean E. Russell
# This software is available under the Ruby license[LICENSE.txt]
#
class Schedule < Graph
# In addition to the defaults set by Graph::initialize and
# Plot::set_defaults, sets:
# [x_label_format] '%Y-%m-%d %H:%M:%S'
# [popup_format] '%Y-%m-%d %H:%M:%S'
def set_defaults
init_with(
:x_label_format => '%Y-%m-%d %H:%M:%S',
:popup_format => '%Y-%m-%d %H:%M:%S',
:scale_x_divisions => false,
:scale_x_integers => false,
:bar_gap => true
)
end
# The format string use do format the X axis labels.
# See Time::strformat
attr_accessor :x_label_format
# Use this to set the spacing between dates on the axis. The value
# must be of the form
# "\d+ ?(days|weeks|months|years|hours|minutes|seconds)?"
#
# EG:
#
# graph.timescale_divisions = "2 weeks"
#
# will cause the chart to try to divide the X axis up into segments of
# two week periods.
attr_accessor :timescale_divisions
# The formatting used for the popups. See x_label_format
attr_accessor :popup_format
attr_accessor :min_x_value
attr_accessor :scale_x_divisions
attr_accessor :scale_x_integers
attr_accessor :bar_gap
# Add data to the plot.
#
# # A data set with 1 point: Lunch from 12:30 to 14:00
# d1 = [ "Lunch", "12:30", "14:00" ]
# # A data set with 2 points: "Cats" runs from 5/11/03 to 7/15/04, and
# # "Henry V" runs from 6/12/03 to 8/20/03
# d2 = [ "Cats", "5/11/03", "7/15/04",
# "Henry V", "6/12/03", "8/20/03" ]
#
# graph.add_data(
# :data => d1,
# :title => 'Meetings'
# )
# graph.add_data(
# :data => d2,
# :title => 'Plays'
# )
#
# Note that the data must be in time,value pairs, and that the date format
# may be any date that is parseable by ParseDate.
# Also note that, in this example, we're mixing scales; the data from d1
# will probably not be discernible if both data sets are plotted on the same
# graph, since d1 is too granular.
def add_data data
@data = [] unless @data
raise "No data provided by #{conf.inspect}" unless data[:data] and
data[:data].kind_of? Array
raise "Data supplied must be title,from,to tripples! "+
"The data provided contained an odd set of "+
"data points" unless data[:data].length % 3 == 0
return if data[:data].length == 0
y = []
x_start = []
x_end = []
data[:data].each_index {|i|
im3 = i%3
if im3 == 0
y << data[:data][i]
else
arr = ParseDate.parsedate( data[:data][i] )
t = Time.local( *arr[0,6].compact )
(im3 == 1 ? x_start : x_end) << t.to_i
end
}
sort( x_start, x_end, y )
@data = [x_start, x_end, y ]
end
protected
def min_x_value=(value)
arr = ParseDate.parsedate( value )
@min_x_value = Time.local( *arr[0,6].compact ).to_i
end
def format x, y
Time.at( x ).strftime( popup_format )
end
def get_x_labels
rv = get_x_values.collect { |v| Time.at(v).strftime( x_label_format ) }
end
def y_label_offset( height )
height / -2.0
end
def get_y_labels
@data[2]
end
def draw_data
fieldheight = field_height
fieldwidth = field_width
bargap = bar_gap ? (fieldheight < 10 ? fieldheight / 2 : 10) : 0
subbar_height = fieldheight - bargap
field_count = 1
y_mod = (subbar_height / 2) + (font_size / 2)
min,max,div = x_range
scale = (@graph_width.to_f - font_size*2) / (max-min)
@data[0].each_index { |i|
x_start = @data[0][i]
x_end = @data[1][i]
y = @graph_height - (fieldheight * field_count)
bar_width = (x_end-x_start) * scale
bar_start = x_start * scale - (min * scale)
@graph.add_element( "rect", {
"x" => bar_start.to_s,
"y" => y.to_s,
"width" => bar_width.to_s,
"height" => subbar_height.to_s,
"class" => "fill#{field_count+1}"
})
field_count += 1
}
end
def get_css
return <<EOL
/* default fill styles for multiple datasets (probably only use a single dataset on this graph though) */
.key1,.fill1{
fill: #ff0000;
fill-opacity: 0.5;
stroke: none;
stroke-width: 0.5px;
}
.key2,.fill2{
fill: #0000ff;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key3,.fill3{
fill: #00ff00;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key4,.fill4{
fill: #ffcc00;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key5,.fill5{
fill: #00ccff;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key6,.fill6{
fill: #ff00ff;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key7,.fill7{
fill: #00ffff;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key8,.fill8{
fill: #ffff00;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key9,.fill9{
fill: #cc6666;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key10,.fill10{
fill: #663399;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key11,.fill11{
fill: #339900;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
.key12,.fill12{
fill: #9966FF;
fill-opacity: 0.5;
stroke: none;
stroke-width: 1px;
}
EOL
end
private
def x_range
max_value = [ @data[0][-1], @data[1].max ].max
min_value = [ @data[0][0], @data[1].min ].min
min_value = min_value<min_x_value ? min_value : min_x_value if min_x_value
range = max_value - min_value
right_pad = range == 0 ? 10 : range / 20.0
scale_range = (max_value + right_pad) - min_value
scale_division = scale_x_divisions || (scale_range / 10.0)
if scale_x_integers
scale_division = scale_division < 1 ? 1 : scale_division.round
end
[min_value, max_value, scale_division]
end
def get_x_values
rv = []
min, max, scale_division = x_range
if timescale_divisions
timescale_divisions =~ /(\d+) ?(days|weeks|months|years|hours|minutes|seconds)?/
division_units = $2 ? $2 : "days"
amount = $1.to_i
if amount
step = nil
case division_units
when "months"
cur = min
while cur < max
rv << cur
arr = Time.at( cur ).to_a
arr[4] += amount
if arr[4] > 12
arr[5] += (arr[4] / 12).to_i
arr[4] = (arr[4] % 12)
end
cur = Time.local(*arr).to_i
end
when "years"
cur = min
while cur < max
rv << cur
arr = Time.at( cur ).to_a
arr[5] += amount
cur = Time.local(*arr).to_i
end
when "weeks"
step = 7 * 24 * 60 * 60 * amount
when "days"
step = 24 * 60 * 60 * amount
when "hours"
step = 60 * 60 * amount
when "minutes"
step = 60 * amount
when "seconds"
step = amount
end
min.step( max, step ) {|v| rv << v} if step
return rv
end
end
min.step( max, scale_division ) {|v| rv << v}
return rv
end
end
end
end

238
lib/SVG/Graph/TimeSeries.rb Normal file
View file

@ -0,0 +1,238 @@
require 'SVG/Graph/Plot'
module SVG
module Graph
# === For creating SVG plots of scalar temporal data
#
# = Synopsis
#
# require 'SVG/Graph/TimeSeriess'
#
# # Data sets are x,y pairs
# data1 = ["6/17/72", 11, "1/11/72", 7, "4/13/04 17:31", 11,
# "9/11/01", 9, "9/1/85", 2, "9/1/88", 1, "1/15/95", 13]
# data2 = ["8/1/73", 18, "3/1/77", 15, "10/1/98", 4,
# "5/1/02", 14, "3/1/95", 6, "8/1/91", 12, "12/1/87", 6,
# "5/1/84", 17, "10/1/80", 12]
#
# graph = SVG::Graph::TimeSeries.new( {
# :width => 640,
# :height => 480,
# :graph_title => title,
# :show_graph_title => true,
# :no_css => true,
# :key => true,
# :scale_x_integers => true,
# :scale_y_integers => true,
# :min_x_value => 0,
# :min_y_value => 0,
# :show_data_labels => true,
# :show_x_guidelines => true,
# :show_x_title => true,
# :x_title => "Time",
# :show_y_title => true,
# :y_title => "Ice Cream Cones",
# :y_title_text_direction => :bt,
# :stagger_x_labels => true,
# :x_label_format => "%m/%d/%y",
# })
#
# graph.add_data({
# :data => projection
# :title => 'Projected',
# })
#
# graph.add_data({
# :data => actual,
# :title => 'Actual',
# })
#
# print graph.burn()
#
# = Description
#
# Produces a graph of temporal scalar data.
#
# = Examples
#
# http://www.germane-software/repositories/public/SVG/test/timeseries.rb
#
# = Notes
#
# The default stylesheet handles upto 10 data sets, if you
# use more you must create your own stylesheet and add the
# additional settings for the extra data sets. You will know
# if you go over 10 data sets as they will have no style and
# be in black.
#
# Unlike the other types of charts, data sets must contain x,y pairs:
#
# [ "12:30", 2 ] # A data set with 1 point: ("12:30",2)
# [ "01:00",2, "14:20",6] # A data set with 2 points: ("01:00",2) and
# # ("14:20",6)
#
# Note that multiple data sets within the same chart can differ in length,
# and that the data in the datasets needn't be in order; they will be ordered
# by the plot along the X-axis.
#
# The dates must be parseable by ParseDate, but otherwise can be
# any order of magnitude (seconds within the hour, or years)
#
# = See also
#
# * SVG::Graph::Graph
# * SVG::Graph::BarHorizontal
# * SVG::Graph::Bar
# * SVG::Graph::Line
# * SVG::Graph::Pie
# * SVG::Graph::Plot
#
# == Author
#
# Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
#
# Copyright 2004 Sean E. Russell
# This software is available under the Ruby license[LICENSE.txt]
#
class TimeSeries < Plot
# In addition to the defaults set by Graph::initialize and
# Plot::set_defaults, sets:
# [x_label_format] '%Y-%m-%d %H:%M:%S'
# [popup_format] '%Y-%m-%d %H:%M:%S'
def set_defaults
super
init_with(
#:max_time_span => '',
:x_label_format => '%Y-%m-%d %H:%M:%S',
:popup_format => '%Y-%m-%d %H:%M:%S'
)
end
# The format string use do format the X axis labels.
# See Time::strformat
attr_accessor :x_label_format
# Use this to set the spacing between dates on the axis. The value
# must be of the form
# "\d+ ?(days|weeks|months|years|hours|minutes|seconds)?"
#
# EG:
#
# graph.timescale_divisions = "2 weeks"
#
# will cause the chart to try to divide the X axis up into segments of
# two week periods.
attr_accessor :timescale_divisions
# The formatting used for the popups. See x_label_format
attr_accessor :popup_format
# Add data to the plot.
#
# d1 = [ "12:30", 2 ] # A data set with 1 point: ("12:30",2)
# d2 = [ "01:00",2, "14:20",6] # A data set with 2 points: ("01:00",2) and
# # ("14:20",6)
# graph.add_data(
# :data => d1,
# :title => 'One'
# )
# graph.add_data(
# :data => d2,
# :title => 'Two'
# )
#
# Note that the data must be in time,value pairs, and that the date format
# may be any date that is parseable by ParseDate.
def add_data data
@data = [] unless @data
raise "No data provided by #{@data.inspect}" unless data[:data] and
data[:data].kind_of? Array
raise "Data supplied must be x,y pairs! "+
"The data provided contained an odd set of "+
"data points" unless data[:data].length % 2 == 0
return if data[:data].length == 0
x = []
y = []
data[:data].each_index {|i|
if i%2 == 0
t = DateTime.parse( data[:data][i] ).to_time
x << t.to_i
else
y << data[:data][i]
end
}
sort( x, y )
data[:data] = [x,y]
@data << data
end
protected
def min_x_value=(value)
@min_x_value = DateTime.parse( value ).to_time
end
def format x, y
Time.at( x ).strftime( popup_format )
end
def get_x_labels
get_x_values.collect { |v| Time.at(v).strftime( x_label_format ) }
end
private
def get_x_values
rv = []
min, max, scale_division = x_range
if timescale_divisions
timescale_divisions =~ /(\d+) ?(day|week|month|year|hour|minute|second)?/
division_units = $2 ? $2 : "day"
amount = $1.to_i
if amount
step = nil
case division_units
when "month"
cur = min
while cur < max
rv << cur
arr = Time.at( cur ).to_a
arr[4] += amount
if arr[4] > 12
arr[5] += (arr[4] / 12).to_i
arr[4] = (arr[4] % 12)
end
cur = Time.local(*arr).to_i
end
when "year"
cur = min
while cur < max
rv << cur
arr = Time.at( cur ).to_a
arr[5] += amount
cur = Time.local(*arr).to_i
end
when "week"
step = 7 * 24 * 60 * 60 * amount
when "day"
step = 24 * 60 * 60 * amount
when "hour"
step = 60 * 60 * amount
when "minute"
step = 60 * amount
when "second"
step = amount
end
min.step( max, step ) {|v| rv << v} if step
return rv
end
end
min.step( max, scale_division ) {|v| rv << v}
return rv
end
end
end
end

57
lib/SVG/LICENSE.txt Normal file
View file

@ -0,0 +1,57 @@
SVG::Graph is copyrighted free software by Sean Russell <ser@germane-software.com>.
You can redistribute it and/or modify it under either the terms of the GPL
(see GPL.txt file), or the conditions below:
1. You may make and give away verbatim copies of the source form of the
software without restriction, provided that you duplicate all of the
original copyright notices and associated disclaimers.
2. You may modify your copy of the software in any way, provided that
you do at least ONE of the following:
a) place your modifications in the Public Domain or otherwise
make them Freely Available, such as by posting said
modifications to Usenet or an equivalent medium, or by allowing
the author to include your modifications in the software.
b) use the modified software only within your corporation or
organization.
c) rename any non-standard executables so the names do not conflict
with standard executables, which must also be provided.
d) make other distribution arrangements with the author.
3. You may distribute the software in object code or executable
form, provided that you do at least ONE of the following:
a) distribute the executables and library files of the software,
together with instructions (in the manual page or equivalent)
on where to get the original distribution.
b) accompany the distribution with the machine-readable source of
the software.
c) give non-standard executables non-standard names, with
instructions on where to get the original software distribution.
d) make other distribution arrangements with the author.
4. You may modify and include the part of the software into any other
software (possibly commercial). But some files in the distribution
are not written by the author, so that they are not under this terms.
All files of this sort are located under the contrib/ directory.
See each file for the copying condition.
5. The scripts and library files supplied as input to or produced as
output from the software do not automatically fall under the
copyright of the software, but belong to whomever generated them,
and may be sold commercially, and may be aggregated with this
software.
6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE.

282
lib/diff.rb Normal file
View file

@ -0,0 +1,282 @@
module RedmineDiff
class Diff
VERSION = 0.3
def Diff.lcs(a, b)
astart = 0
bstart = 0
afinish = a.length-1
bfinish = b.length-1
mvector = []
# First we prune off any common elements at the beginning
while (astart <= afinish && bstart <= afinish && a[astart] == b[bstart])
mvector[astart] = bstart
astart += 1
bstart += 1
end
# now the end
while (astart <= afinish && bstart <= bfinish && a[afinish] == b[bfinish])
mvector[afinish] = bfinish
afinish -= 1
bfinish -= 1
end
bmatches = b.reverse_hash(bstart..bfinish)
thresh = []
links = []
(astart..afinish).each { |aindex|
aelem = a[aindex]
next unless bmatches.has_key? aelem
k = nil
bmatches[aelem].reverse.each { |bindex|
if k && (thresh[k] > bindex) && (thresh[k-1] < bindex)
thresh[k] = bindex
else
k = thresh.replacenextlarger(bindex, k)
end
links[k] = [ (k==0) ? nil : links[k-1], aindex, bindex ] if k
}
}
if !thresh.empty?
link = links[thresh.length-1]
while link
mvector[link[1]] = link[2]
link = link[0]
end
end
return mvector
end
def makediff(a, b)
mvector = Diff.lcs(a, b)
ai = bi = 0
while ai < mvector.length
bline = mvector[ai]
if bline
while bi < bline
discardb(bi, b[bi])
bi += 1
end
match(ai, bi)
bi += 1
else
discarda(ai, a[ai])
end
ai += 1
end
while ai < a.length
discarda(ai, a[ai])
ai += 1
end
while bi < b.length
discardb(bi, b[bi])
bi += 1
end
match(ai, bi)
1
end
def compactdiffs
diffs = []
@diffs.each { |df|
i = 0
curdiff = []
while i < df.length
whot = df[i][0]
s = @isstring ? df[i][2].chr : [df[i][2]]
p = df[i][1]
last = df[i][1]
i += 1
while df[i] && df[i][0] == whot && df[i][1] == last+1
s << df[i][2]
last = df[i][1]
i += 1
end
curdiff.push [whot, p, s]
end
diffs.push curdiff
}
return diffs
end
attr_reader :diffs, :difftype
def initialize(diffs_or_a, b = nil, isstring = nil)
if b.nil?
@diffs = diffs_or_a
@isstring = isstring
else
@diffs = []
@curdiffs = []
makediff(diffs_or_a, b)
@difftype = diffs_or_a.class
end
end
def match(ai, bi)
@diffs.push @curdiffs unless @curdiffs.empty?
@curdiffs = []
end
def discarda(i, elem)
@curdiffs.push ['-', i, elem]
end
def discardb(i, elem)
@curdiffs.push ['+', i, elem]
end
def compact
return Diff.new(compactdiffs)
end
def compact!
@diffs = compactdiffs
end
def inspect
@diffs.inspect
end
end
end
module Diffable
def diff(b)
RedmineDiff::Diff.new(self, b)
end
# Create a hash that maps elements of the array to arrays of indices
# where the elements are found.
def reverse_hash(range = (0...self.length))
revmap = {}
range.each { |i|
elem = self[i]
if revmap.has_key? elem
revmap[elem].push i
else
revmap[elem] = [i]
end
}
return revmap
end
def replacenextlarger(value, high = nil)
high ||= self.length
if self.empty? || value > self[-1]
push value
return high
end
# binary search for replacement point
low = 0
while low < high
index = (high+low)/2
found = self[index]
return nil if value == found
if value > found
low = index + 1
else
high = index
end
end
self[low] = value
# $stderr << "replace #{value} : 0/#{low}/#{init_high} (#{steps} steps) (#{init_high-low} off )\n"
# $stderr.puts self.inspect
#gets
#p length - low
return low
end
def patch(diff)
newary = nil
if diff.difftype == String
newary = diff.difftype.new('')
else
newary = diff.difftype.new
end
ai = 0
bi = 0
diff.diffs.each { |d|
d.each { |mod|
case mod[0]
when '-'
while ai < mod[1]
newary << self[ai]
ai += 1
bi += 1
end
ai += 1
when '+'
while bi < mod[1]
newary << self[ai]
ai += 1
bi += 1
end
newary << mod[2]
bi += 1
else
raise "Unknown diff action"
end
}
}
while ai < self.length
newary << self[ai]
ai += 1
bi += 1
end
return newary
end
end
class Array
include Diffable
end
class String
include Diffable
end
=begin
= Diff
(({diff.rb})) - computes the differences between two arrays or
strings. Copyright (C) 2001 Lars Christensen
== Synopsis
diff = Diff.new(a, b)
b = a.patch(diff)
== Class Diff
=== Class Methods
--- Diff.new(a, b)
--- a.diff(b)
Creates a Diff object which represent the differences between
((|a|)) and ((|b|)). ((|a|)) and ((|b|)) can be either be arrays
of any objects, strings, or object of any class that include
module ((|Diffable|))
== Module Diffable
The module ((|Diffable|)) is intended to be included in any class for
which differences are to be computed. Diffable is included into String
and Array when (({diff.rb})) is (({require}))'d.
Classes including Diffable should implement (({[]})) to get element at
integer indices, (({<<})) to append elements to the object and
(({ClassName#new})) should accept 0 arguments to create a new empty
object.
=== Instance Methods
--- Diffable#patch(diff)
Applies the differences from ((|diff|)) to the object ((|obj|))
and return the result. ((|obj|)) is not changed. ((|obj|)) and
can be either an array or a string, but must match the object
from which the ((|diff|)) was created.
=end

View file

@ -0,0 +1,26 @@
Description:
The plugin generator creates stubs for a new Redmine plugin.
Example:
./script/rails generate redmine_plugin meetings
create plugins/meetings/app
create plugins/meetings/app/controllers
create plugins/meetings/app/helpers
create plugins/meetings/app/models
create plugins/meetings/app/views
create plugins/meetings/db/migrate
create plugins/meetings/lib/tasks
create plugins/meetings/assets/images
create plugins/meetings/assets/javascripts
create plugins/meetings/assets/stylesheets
create plugins/meetings/config/locales
create plugins/meetings/test
create plugins/meetings/test/fixtures
create plugins/meetings/test/unit
create plugins/meetings/test/functional
create plugins/meetings/test/integration
create plugins/meetings/README.rdoc
create plugins/meetings/init.rb
create plugins/meetings/config/routes.rb
create plugins/meetings/config/locales/en.yml
create plugins/meetings/test/test_helper.rb

View file

@ -0,0 +1,37 @@
class RedminePluginGenerator < Rails::Generators::NamedBase
source_root File.expand_path("../templates", __FILE__)
attr_reader :plugin_path, :plugin_name, :plugin_pretty_name
def initialize(*args)
super
@plugin_name = file_name.underscore
@plugin_pretty_name = plugin_name.titleize
@plugin_path = File.join(Redmine::Plugin.directory, plugin_name)
end
def copy_templates
empty_directory "#{plugin_path}/app"
empty_directory "#{plugin_path}/app/controllers"
empty_directory "#{plugin_path}/app/helpers"
empty_directory "#{plugin_path}/app/models"
empty_directory "#{plugin_path}/app/views"
empty_directory "#{plugin_path}/db/migrate"
empty_directory "#{plugin_path}/lib/tasks"
empty_directory "#{plugin_path}/assets/images"
empty_directory "#{plugin_path}/assets/javascripts"
empty_directory "#{plugin_path}/assets/stylesheets"
empty_directory "#{plugin_path}/config/locales"
empty_directory "#{plugin_path}/test"
empty_directory "#{plugin_path}/test/fixtures"
empty_directory "#{plugin_path}/test/unit"
empty_directory "#{plugin_path}/test/functional"
empty_directory "#{plugin_path}/test/integration"
template 'README.rdoc', "#{plugin_path}/README.rdoc"
template 'init.rb.erb', "#{plugin_path}/init.rb"
template 'routes.rb', "#{plugin_path}/config/routes.rb"
template 'en_rails_i18n.yml', "#{plugin_path}/config/locales/en.yml"
template 'test_helper.rb.erb', "#{plugin_path}/test/test_helper.rb"
end
end

View file

@ -0,0 +1,3 @@
= <%= file_name %>
Description goes here

View file

@ -0,0 +1,3 @@
# English strings go here for Rails i18n
en:
# my_label: "My label"

View file

@ -0,0 +1,8 @@
Redmine::Plugin.register :<%= plugin_name %> do
name '<%= plugin_pretty_name %> plugin'
author 'Author name'
description 'This is a plugin for Redmine'
version '0.0.1'
url 'http://example.com/path/to/plugin'
author_url 'http://example.com/about'
end

View file

@ -0,0 +1,2 @@
# Plugin's routes
# See: http://guides.rubyonrails.org/routing.html

View file

@ -0,0 +1,2 @@
# Load the Redmine helper
require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper')

View file

@ -0,0 +1,5 @@
Description:
Generates a plugin controller.
Example:
./script/rails generate redmine_plugin_controller meetings pools index show vote

View file

@ -0,0 +1,27 @@
class RedminePluginControllerGenerator < Rails::Generators::NamedBase
source_root File.expand_path("../templates", __FILE__)
argument :controller, :type => :string
argument :actions, :type => :array, :default => [], :banner => "ACTION ACTION ..."
attr_reader :plugin_path, :plugin_name, :plugin_pretty_name
def initialize(*args)
super
@plugin_name = file_name.underscore
@plugin_pretty_name = plugin_name.titleize
@plugin_path = File.join(Redmine::Plugin.directory, plugin_name)
@controller_class = controller.camelize
end
def copy_templates
template 'controller.rb.erb', "#{plugin_path}/app/controllers/#{controller}_controller.rb"
template 'helper.rb.erb', "#{plugin_path}/app/helpers/#{controller}_helper.rb"
template 'functional_test.rb.erb', "#{plugin_path}/test/functional/#{controller}_controller_test.rb"
# View template for each action.
actions.each do |action|
path = "#{plugin_path}/app/views/#{controller}/#{action}.html.erb"
@action_name = action
template 'view.html.erb', path
end
end
end

View file

@ -0,0 +1,7 @@
class <%= @controller_class %>Controller < ApplicationController
<% actions.each do |action| -%>
def <%= action %>
end
<% end -%>
end

View file

@ -0,0 +1,8 @@
require File.expand_path('../../test_helper', __FILE__)
class <%= @controller_class %>ControllerTest < ActionController::TestCase
# Replace this with your real tests.
def test_truth
assert true
end
end

View file

@ -0,0 +1,2 @@
module <%= @controller_class %>Helper
end

View file

@ -0,0 +1 @@
<h2><%= @controller_class %>Controller#<%= @action_name %></h2>

View file

@ -0,0 +1,5 @@
Description:
Generates a plugin model.
Examples:
./script/rails generate redmine_plugin_model meetings pool

View file

@ -0,0 +1,41 @@
class RedminePluginModelGenerator < Rails::Generators::NamedBase
source_root File.expand_path("../templates", __FILE__)
argument :model, :type => :string
argument :attributes, :type => :array, :default => [], :banner => "field[:type][:index] field[:type][:index]"
class_option :migration, :type => :boolean
class_option :timestamps, :type => :boolean
class_option :parent, :type => :string, :desc => "The parent class for the generated model"
class_option :indexes, :type => :boolean, :default => true, :desc => "Add indexes for references and belongs_to columns"
attr_reader :plugin_path, :plugin_name, :plugin_pretty_name
def initialize(*args)
super
@plugin_name = file_name.underscore
@plugin_pretty_name = plugin_name.titleize
@plugin_path = File.join(Redmine::Plugin.directory, plugin_name)
@model_class = model.camelize
@table_name = @model_class.tableize
@migration_filename = "create_#{@table_name}"
@migration_class_name = @migration_filename.camelize
end
def copy_templates
template 'model.rb.erb', "#{plugin_path}/app/models/#{model.underscore}.rb"
template 'unit_test.rb.erb', "#{plugin_path}/test/unit/#{model.underscore}_test.rb"
migration_filename = "%03i_#{@migration_filename}.rb" % (migration_number + 1)
template "migration.rb", "#{plugin_path}/db/migrate/#{migration_filename}"
end
def attributes_with_index
attributes.select { |a| a.has_index? || (a.reference? && options[:indexes]) }
end
def migration_number
current = Dir.glob("#{plugin_path}/db/migrate/*.rb").map do |file|
File.basename(file).split("_").first.to_i
end.max.to_i
end
end

View file

@ -0,0 +1,15 @@
class <%= @migration_class_name %> < ActiveRecord::Migration
def change
create_table :<%= @table_name %> do |t|
<% attributes.each do |attribute| -%>
t.<%= attribute.type %> :<%= attribute.name %><%= attribute.inject_options %>
<% end -%>
<% if options[:timestamps] %>
t.timestamps
<% end -%>
end
<% attributes_with_index.each do |attribute| -%>
add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>
<% end -%>
end
end

View file

@ -0,0 +1,2 @@
class <%= @model_class %> < ActiveRecord::Base
end

View file

@ -0,0 +1,9 @@
require File.expand_path('../../test_helper', __FILE__)
class <%= @model_class %>Test < ActiveSupport::TestCase
# Replace this with your real tests.
def test_truth
assert true
end
end

View file

@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_activity_provider'
ActiveRecord::Base.send(:include, Redmine::Acts::ActivityProvider)

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 Acts
module ActivityProvider
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_activity_provider(options = {})
unless self.included_modules.include?(Redmine::Acts::ActivityProvider::InstanceMethods)
cattr_accessor :activity_provider_options
send :include, Redmine::Acts::ActivityProvider::InstanceMethods
end
options.assert_valid_keys(:type, :permission, :timestamp, :author_key, :scope)
self.activity_provider_options ||= {}
# One model can provide different event types
# We store these options in activity_provider_options hash
event_type = options.delete(:type) || self.name.underscore.pluralize
options[:timestamp] ||= "#{table_name}.created_on"
options[:author_key] = "#{table_name}.#{options[:author_key]}" if options[:author_key].is_a?(Symbol)
self.activity_provider_options[event_type] = options
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
# Returns events of type event_type visible by user that occurred between from and to
def find_events(event_type, user, from, to, options)
provider_options = activity_provider_options[event_type]
raise "#{self.name} can not provide #{event_type} events." if provider_options.nil?
scope = (provider_options[:scope] || self)
if from && to
scope = scope.where("#{provider_options[:timestamp]} BETWEEN ? AND ?", from, to)
end
if options[:author]
return [] if provider_options[:author_key].nil?
scope = scope.where("#{provider_options[:author_key]} = ?", options[:author].id)
end
if options[:limit]
# id and creation time should be in same order in most cases
scope = scope.reorder("#{table_name}.id DESC").limit(options[:limit])
end
if provider_options.has_key?(:permission)
scope = scope.where(Project.allowed_to_condition(user, provider_options[:permission] || :view_project, options))
elsif respond_to?(:visible)
scope = scope.visible(user, options)
else
ActiveSupport::Deprecation.warn "acts_as_activity_provider with implicit :permission option is deprecated. Add a visible scope to the #{self.name} model or use explicit :permission option."
scope = scope.where(Project.allowed_to_condition(user, "view_#{self.name.underscore.pluralize}".to_sym, options))
end
scope.to_a
end
end
end
end
end
end

View file

@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_attachable'
ActiveRecord::Base.send(:include, Redmine::Acts::Attachable)

View file

@ -0,0 +1,139 @@
# 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 Attachable
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_attachable(options = {})
cattr_accessor :attachable_options
self.attachable_options = {}
attachable_options[:view_permission] = options.delete(:view_permission) || "view_#{self.name.pluralize.underscore}".to_sym
attachable_options[:edit_permission] = options.delete(:edit_permission) || "edit_#{self.name.pluralize.underscore}".to_sym
attachable_options[:delete_permission] = options.delete(:delete_permission) || "edit_#{self.name.pluralize.underscore}".to_sym
has_many :attachments, lambda {order("#{Attachment.table_name}.created_on ASC, #{Attachment.table_name}.id ASC")},
options.merge(:as => :container, :dependent => :destroy, :inverse_of => :container)
send :include, Redmine::Acts::Attachable::InstanceMethods
before_save :attach_saved_attachments
after_rollback :detach_saved_attachments
validate :warn_about_failed_attachments
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
def attachments_visible?(user=User.current)
(respond_to?(:visible?) ? visible?(user) : true) &&
user.allowed_to?(self.class.attachable_options[:view_permission], self.project)
end
def attachments_editable?(user=User.current)
(respond_to?(:visible?) ? visible?(user) : true) &&
user.allowed_to?(self.class.attachable_options[:edit_permission], self.project)
end
def attachments_deletable?(user=User.current)
(respond_to?(:visible?) ? visible?(user) : true) &&
user.allowed_to?(self.class.attachable_options[:delete_permission], self.project)
end
def saved_attachments
@saved_attachments ||= []
end
def unsaved_attachments
@unsaved_attachments ||= []
end
def save_attachments(attachments, author=User.current)
if attachments.is_a?(Hash)
attachments = attachments.stringify_keys
attachments = attachments.to_a.sort {|a, b|
if a.first.to_i > 0 && b.first.to_i > 0
a.first.to_i <=> b.first.to_i
elsif a.first.to_i > 0
1
elsif b.first.to_i > 0
-1
else
a.first <=> b.first
end
}
attachments = attachments.map(&:last)
end
if attachments.is_a?(Array)
@failed_attachment_count = 0
attachments.each do |attachment|
next unless attachment.is_a?(Hash)
a = nil
if file = attachment['file']
a = Attachment.create(:file => file, :author => author)
elsif token = attachment['token'].presence
a = Attachment.find_by_token(token)
unless a
@failed_attachment_count += 1
next
end
a.filename = attachment['filename'] unless attachment['filename'].blank?
a.content_type = attachment['content_type'] unless attachment['content_type'].blank?
end
next unless a
a.description = attachment['description'].to_s.strip
if a.new_record?
unsaved_attachments << a
else
saved_attachments << a
end
end
end
{:files => saved_attachments, :unsaved => unsaved_attachments}
end
def attach_saved_attachments
saved_attachments.each do |attachment|
self.attachments << attachment
end
end
def detach_saved_attachments
saved_attachments.each do |attachment|
# TODO: use #reload instead, after upgrading to Rails 5
# (after_rollback is called when running transactional tests in Rails 4)
attachment.container = nil
end
end
def warn_about_failed_attachments
if @failed_attachment_count && @failed_attachment_count > 0
errors.add :base, ::I18n.t('warning_attachments_not_saved', count: @failed_attachment_count)
end
end
module ClassMethods
end
end
end
end
end

View file

@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_customizable'
ActiveRecord::Base.send(:include, Redmine::Acts::Customizable)

View file

@ -0,0 +1,169 @@
# 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 Customizable
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_customizable(options = {})
return if self.included_modules.include?(Redmine::Acts::Customizable::InstanceMethods)
cattr_accessor :customizable_options
self.customizable_options = options
has_many :custom_values, lambda {includes(:custom_field).order("#{CustomField.table_name}.position")},
:as => :customized,
:inverse_of => :customized,
:dependent => :delete_all,
:validate => false
send :include, Redmine::Acts::Customizable::InstanceMethods
validate :validate_custom_field_values
after_save :save_custom_field_values
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
def available_custom_fields
CustomField.where("type = '#{self.class.name}CustomField'").sorted.to_a
end
# Sets the values of the object's custom fields
# values is an array like [{'id' => 1, 'value' => 'foo'}, {'id' => 2, 'value' => 'bar'}]
def custom_fields=(values)
values_to_hash = values.inject({}) do |hash, v|
v = v.stringify_keys
if v['id'] && v.has_key?('value')
hash[v['id']] = v['value']
end
hash
end
self.custom_field_values = values_to_hash
end
# Sets the values of the object's custom fields
# values is a hash like {'1' => 'foo', 2 => 'bar'}
def custom_field_values=(values)
values = values.stringify_keys
custom_field_values.each do |custom_field_value|
key = custom_field_value.custom_field_id.to_s
if values.has_key?(key)
custom_field_value.value = values[key]
end
end
@custom_field_values_changed = true
end
def custom_field_values
@custom_field_values ||= available_custom_fields.collect do |field|
x = CustomFieldValue.new
x.custom_field = field
x.customized = self
if field.multiple?
values = custom_values.select { |v| v.custom_field == field }
if values.empty?
values << custom_values.build(:customized => self, :custom_field => field)
end
x.instance_variable_set("@value", values.map(&:value))
else
cv = custom_values.detect { |v| v.custom_field == field }
cv ||= custom_values.build(:customized => self, :custom_field => field)
x.instance_variable_set("@value", cv.value)
end
x.value_was = x.value.dup if x.value
x
end
end
def visible_custom_field_values
custom_field_values.select(&:visible?)
end
def custom_field_values_changed?
@custom_field_values_changed == true
end
def custom_value_for(c)
field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
custom_values.detect {|v| v.custom_field_id == field_id }
end
def custom_field_value(c)
field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
custom_field_values.detect {|v| v.custom_field_id == field_id }.try(:value)
end
def validate_custom_field_values
if new_record? || custom_field_values_changed?
custom_field_values.each(&:validate_value)
end
end
def save_custom_field_values
target_custom_values = []
custom_field_values.each do |custom_field_value|
if custom_field_value.value.is_a?(Array)
custom_field_value.value.each do |v|
target = custom_values.detect {|cv| cv.custom_field == custom_field_value.custom_field && cv.value == v}
target ||= custom_values.build(:customized => self, :custom_field => custom_field_value.custom_field, :value => v)
target_custom_values << target
end
else
target = custom_values.detect {|cv| cv.custom_field == custom_field_value.custom_field}
target ||= custom_values.build(:customized => self, :custom_field => custom_field_value.custom_field)
target.value = custom_field_value.value
target_custom_values << target
end
end
self.custom_values = target_custom_values
custom_values.each(&:save)
@custom_field_values_changed = false
true
end
def reassign_custom_field_values
if @custom_field_values
values = @custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
@custom_field_values = nil
self.custom_field_values = values
end
end
def reset_custom_values!
@custom_field_values = nil
@custom_field_values_changed = true
end
def reload(*args)
@custom_field_values = nil
@custom_field_values_changed = false
super
end
module ClassMethods
end
end
end
end
end

View file

@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_event'
ActiveRecord::Base.send(:include, Redmine::Acts::Event)

View file

@ -0,0 +1,96 @@
# 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 Event
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_event(options = {})
return if self.included_modules.include?(Redmine::Acts::Event::InstanceMethods)
default_options = { :datetime => :created_on,
:title => :title,
:description => :description,
:author => :author,
:url => {:controller => 'welcome'},
:type => self.name.underscore.dasherize }
cattr_accessor :event_options
self.event_options = default_options.merge(options)
send :include, Redmine::Acts::Event::InstanceMethods
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
%w(datetime title description author type).each do |attr|
src = <<-END_SRC
def event_#{attr}
option = event_options[:#{attr}]
if option.is_a?(Proc)
option.call(self)
elsif option.is_a?(Symbol)
send(option)
else
option
end
end
END_SRC
class_eval src, __FILE__, __LINE__
end
def event_date
event_datetime.to_date
end
def event_group
group = event_options[:group] ? send(event_options[:group]) : self
group || self
end
def event_url(options = {})
option = event_options[:url]
if option.is_a?(Proc)
option.call(self).merge(options)
elsif option.is_a?(Hash)
option.merge(options)
elsif option.is_a?(Symbol)
send(option).merge(options)
else
option
end
end
# Returns the mail addresses of users that should be notified
def recipients
notified = project.notified_users
notified.reject! {|user| !visible?(user)}
notified.collect(&:mail)
end
module ClassMethods
end
end
end
end
end

View file

@ -0,0 +1,23 @@
ActsAsList
==========
This acts_as extension provides the capabilities for sorting and reordering a number of objects in a list. The class that has this specified needs to have a +position+ column defined as an integer on the mapped database table.
Example
=======
class TodoList < ActiveRecord::Base
has_many :todo_items, :order => "position"
end
class TodoItem < ActiveRecord::Base
belongs_to :todo_list
acts_as_list :scope => :todo_list
end
todo_list.first.move_to_bottom
todo_list.last.move_higher
Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license

View file

@ -0,0 +1,3 @@
$:.unshift "#{File.dirname(__FILE__)}/lib"
require 'active_record/acts/list'
ActiveRecord::Base.class_eval { include ActiveRecord::Acts::List }

View file

@ -0,0 +1,281 @@
module ActiveRecord
module Acts #:nodoc:
module List #:nodoc:
def self.included(base)
base.extend(ClassMethods)
end
# This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list.
# The class that has this specified needs to have a +position+ column defined as an integer on
# the mapped database table.
#
# Todo list example:
#
# class TodoList < ActiveRecord::Base
# has_many :todo_items, :order => "position"
# end
#
# class TodoItem < ActiveRecord::Base
# belongs_to :todo_list
# acts_as_list :scope => :todo_list
# end
#
# todo_list.first.move_to_bottom
# todo_list.last.move_higher
module ClassMethods
# Configuration options are:
#
# * +column+ - specifies the column name to use for keeping the position integer (default: +position+)
# * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach <tt>_id</tt>
# (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
# to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
# Example: <tt>acts_as_list :scope => 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
def acts_as_list(options = {})
ActiveSupport::Deprecation.warn "The acts_as_list plugin will be removed from Redmine 4 core, use the acts_as_list gem or similar implementation instead."
configuration = { :column => "position", :scope => "1 = 1" }
configuration.update(options) if options.is_a?(Hash)
configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
if configuration[:scope].is_a?(Symbol)
scope_condition_method = %(
def scope_condition
if #{configuration[:scope].to_s}.nil?
"#{configuration[:scope].to_s} IS NULL"
else
"#{configuration[:scope].to_s} = \#{#{configuration[:scope].to_s}}"
end
end
)
else
scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end"
end
class_eval <<-EOV
include ActiveRecord::Acts::List::InstanceMethods
def acts_as_list_class
::#{self.name}
end
def position_column
'#{configuration[:column]}'
end
#{scope_condition_method}
before_destroy :remove_from_list
before_create :add_to_list_bottom
EOV
end
end
# All the methods available to a record that has had <tt>acts_as_list</tt> specified. Each method works
# by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
# lower in the list of all chapters. Likewise, <tt>chapter.first?</tt> would return +true+ if that chapter is
# the first in the list of all chapters.
module InstanceMethods
# Insert the item at the given position (defaults to the top position of 1).
def insert_at(position = 1)
insert_at_position(position)
end
# Swap positions with the next lower item, if one exists.
def move_lower
return unless lower_item
acts_as_list_class.transaction do
lower_item.decrement_position
increment_position
end
end
# Swap positions with the next higher item, if one exists.
def move_higher
return unless higher_item
acts_as_list_class.transaction do
higher_item.increment_position
decrement_position
end
end
# Move to the bottom of the list. If the item is already in the list, the items below it have their
# position adjusted accordingly.
def move_to_bottom
return unless in_list?
acts_as_list_class.transaction do
decrement_positions_on_lower_items
assume_bottom_position
end
end
# Move to the top of the list. If the item is already in the list, the items above it have their
# position adjusted accordingly.
def move_to_top
return unless in_list?
acts_as_list_class.transaction do
increment_positions_on_higher_items
assume_top_position
end
end
# Move to the given position
def move_to=(pos)
case pos.to_s
when 'highest'
move_to_top
when 'higher'
move_higher
when 'lower'
move_lower
when 'lowest'
move_to_bottom
end
reset_positions_in_list
end
def reset_positions_in_list
acts_as_list_class.where(scope_condition).reorder("#{position_column} ASC, id ASC").each_with_index do |item, i|
unless item.send(position_column) == (i + 1)
acts_as_list_class.where({:id => item.id}).
update_all({position_column => (i + 1)})
end
end
end
# Removes the item from the list.
def remove_from_list
if in_list?
decrement_positions_on_lower_items
update_attribute position_column, nil
end
end
# Increase the position of this item without adjusting the rest of the list.
def increment_position
return unless in_list?
update_attribute position_column, self.send(position_column).to_i + 1
end
# Decrease the position of this item without adjusting the rest of the list.
def decrement_position
return unless in_list?
update_attribute position_column, self.send(position_column).to_i - 1
end
# Return +true+ if this object is the first in the list.
def first?
return false unless in_list?
self.send(position_column) == 1
end
# Return +true+ if this object is the last in the list.
def last?
return false unless in_list?
self.send(position_column) == bottom_position_in_list
end
# Return the next higher item in the list.
def higher_item
return nil unless in_list?
acts_as_list_class.where(
"#{scope_condition} AND #{position_column} = #{(send(position_column).to_i - 1).to_s}"
).first
end
# Return the next lower item in the list.
def lower_item
return nil unless in_list?
acts_as_list_class.where(
"#{scope_condition} AND #{position_column} = #{(send(position_column).to_i + 1).to_s}"
).first
end
# Test if this record is in a list
def in_list?
!send(position_column).nil?
end
private
def add_to_list_top
increment_positions_on_all_items
end
def add_to_list_bottom
self[position_column] = bottom_position_in_list.to_i + 1
end
# Overwrite this method to define the scope of the list changes
def scope_condition() "1" end
# Returns the bottom position number in the list.
# bottom_position_in_list # => 2
def bottom_position_in_list(except = nil)
item = bottom_item(except)
item ? item.send(position_column) : 0
end
# Returns the bottom item
def bottom_item(except = nil)
conditions = scope_condition
conditions = "#{conditions} AND #{self.class.primary_key} != #{except.id}" if except
acts_as_list_class.where(conditions).reorder("#{position_column} DESC").first
end
# Forces item to assume the bottom position in the list.
def assume_bottom_position
update_attribute(position_column, bottom_position_in_list(self).to_i + 1)
end
# Forces item to assume the top position in the list.
def assume_top_position
update_attribute(position_column, 1)
end
# This has the effect of moving all the higher items up one.
def decrement_positions_on_higher_items(position)
acts_as_list_class.
where("#{scope_condition} AND #{position_column} <= #{position}").
update_all("#{position_column} = (#{position_column} - 1)")
end
# This has the effect of moving all the lower items up one.
def decrement_positions_on_lower_items
return unless in_list?
acts_as_list_class.
where("#{scope_condition} AND #{position_column} > #{send(position_column).to_i}").
update_all("#{position_column} = (#{position_column} - 1)")
end
# This has the effect of moving all the higher items down one.
def increment_positions_on_higher_items
return unless in_list?
acts_as_list_class.
where("#{scope_condition} AND #{position_column} < #{send(position_column).to_i}").
update_all("#{position_column} = (#{position_column} + 1)")
end
# This has the effect of moving all the lower items down one.
def increment_positions_on_lower_items(position)
acts_as_list_class.
where("#{scope_condition} AND #{position_column} >= #{position}").
update_all("#{position_column} = (#{position_column} + 1)")
end
# Increments position (<tt>position_column</tt>) of all items in the list.
def increment_positions_on_all_items
acts_as_list_class.
where("#{scope_condition}").
update_all("#{position_column} = (#{position_column} + 1)")
end
def insert_at_position(position)
remove_from_list
increment_positions_on_lower_items(position)
self.update_attribute(position_column, position)
end
end
end
end
end

View file

@ -0,0 +1,332 @@
require 'test/unit'
require 'rubygems'
gem 'activerecord', '>= 1.15.4.7794'
require 'active_record'
require "#{File.dirname(__FILE__)}/../init"
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")
def setup_db
ActiveRecord::Schema.define(:version => 1) do
create_table :mixins do |t|
t.column :pos, :integer
t.column :parent_id, :integer
t.column :created_at, :datetime
t.column :updated_at, :datetime
end
end
end
def teardown_db
ActiveRecord::Base.connection.tables.each do |table|
ActiveRecord::Base.connection.drop_table(table)
end
end
class Mixin < ActiveRecord::Base
end
class ListMixin < Mixin
acts_as_list :column => "pos", :scope => :parent
def self.table_name() "mixins" end
end
class ListMixinSub1 < ListMixin
end
class ListMixinSub2 < ListMixin
end
class ListWithStringScopeMixin < ActiveRecord::Base
acts_as_list :column => "pos", :scope => 'parent_id = #{parent_id}'
def self.table_name() "mixins" end
end
class ListTest < Test::Unit::TestCase
def setup
setup_db
(1..4).each { |counter| ListMixin.create! :pos => counter, :parent_id => 5 }
end
def teardown
teardown_db
end
def test_reordering
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).move_lower
assert_equal [1, 3, 2, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).move_higher
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(1).move_to_bottom
assert_equal [2, 3, 4, 1], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(1).move_to_top
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).move_to_bottom
assert_equal [1, 3, 4, 2], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(4).move_to_top
assert_equal [4, 1, 3, 2], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
end
def test_move_to_bottom_with_next_to_last_item
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(3).move_to_bottom
assert_equal [1, 2, 4, 3], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
end
def test_next_prev
assert_equal ListMixin.find(2), ListMixin.find(1).lower_item
assert_nil ListMixin.find(1).higher_item
assert_equal ListMixin.find(3), ListMixin.find(4).higher_item
assert_nil ListMixin.find(4).lower_item
end
def test_injection
item = ListMixin.new(:parent_id => 1)
assert_equal "parent_id = 1", item.scope_condition
assert_equal "pos", item.position_column
end
def test_insert
new = ListMixin.create(:parent_id => 20)
assert_equal 1, new.pos
assert new.first?
assert new.last?
new = ListMixin.create(:parent_id => 20)
assert_equal 2, new.pos
assert !new.first?
assert new.last?
new = ListMixin.create(:parent_id => 20)
assert_equal 3, new.pos
assert !new.first?
assert new.last?
new = ListMixin.create(:parent_id => 0)
assert_equal 1, new.pos
assert new.first?
assert new.last?
end
def test_insert_at
new = ListMixin.create(:parent_id => 20)
assert_equal 1, new.pos
new = ListMixin.create(:parent_id => 20)
assert_equal 2, new.pos
new = ListMixin.create(:parent_id => 20)
assert_equal 3, new.pos
new4 = ListMixin.create(:parent_id => 20)
assert_equal 4, new4.pos
new4.insert_at(3)
assert_equal 3, new4.pos
new.reload
assert_equal 4, new.pos
new.insert_at(2)
assert_equal 2, new.pos
new4.reload
assert_equal 4, new4.pos
new5 = ListMixin.create(:parent_id => 20)
assert_equal 5, new5.pos
new5.insert_at(1)
assert_equal 1, new5.pos
new4.reload
assert_equal 5, new4.pos
end
def test_delete_middle
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).destroy
assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(1).pos
assert_equal 2, ListMixin.find(3).pos
assert_equal 3, ListMixin.find(4).pos
ListMixin.find(1).destroy
assert_equal [3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(3).pos
assert_equal 2, ListMixin.find(4).pos
end
def test_with_string_based_scope
new = ListWithStringScopeMixin.create(:parent_id => 500)
assert_equal 1, new.pos
assert new.first?
assert new.last?
end
def test_nil_scope
new1, new2, new3 = ListMixin.create, ListMixin.create, ListMixin.create
new2.move_higher
assert_equal [new2, new1, new3], ListMixin.find(:all, :conditions => 'parent_id IS NULL', :order => 'pos')
end
def test_remove_from_list_should_then_fail_in_list?
assert_equal true, ListMixin.find(1).in_list?
ListMixin.find(1).remove_from_list
assert_equal false, ListMixin.find(1).in_list?
end
def test_remove_from_list_should_set_position_to_nil
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).remove_from_list
assert_equal [2, 1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(1).pos
assert_equal nil, ListMixin.find(2).pos
assert_equal 2, ListMixin.find(3).pos
assert_equal 3, ListMixin.find(4).pos
end
def test_remove_before_destroy_does_not_shift_lower_items_twice
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).remove_from_list
ListMixin.find(2).destroy
assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(1).pos
assert_equal 2, ListMixin.find(3).pos
assert_equal 3, ListMixin.find(4).pos
end
end
class ListSubTest < Test::Unit::TestCase
def setup
setup_db
(1..4).each { |i| ((i % 2 == 1) ? ListMixinSub1 : ListMixinSub2).create! :pos => i, :parent_id => 5000 }
end
def teardown
teardown_db
end
def test_reordering
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(2).move_lower
assert_equal [1, 3, 2, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(2).move_higher
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(1).move_to_bottom
assert_equal [2, 3, 4, 1], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(1).move_to_top
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(2).move_to_bottom
assert_equal [1, 3, 4, 2], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(4).move_to_top
assert_equal [4, 1, 3, 2], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
end
def test_move_to_bottom_with_next_to_last_item
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(3).move_to_bottom
assert_equal [1, 2, 4, 3], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
end
def test_next_prev
assert_equal ListMixin.find(2), ListMixin.find(1).lower_item
assert_nil ListMixin.find(1).higher_item
assert_equal ListMixin.find(3), ListMixin.find(4).higher_item
assert_nil ListMixin.find(4).lower_item
end
def test_injection
item = ListMixin.new("parent_id"=>1)
assert_equal "parent_id = 1", item.scope_condition
assert_equal "pos", item.position_column
end
def test_insert_at
new = ListMixin.create("parent_id" => 20)
assert_equal 1, new.pos
new = ListMixinSub1.create("parent_id" => 20)
assert_equal 2, new.pos
new = ListMixinSub2.create("parent_id" => 20)
assert_equal 3, new.pos
new4 = ListMixin.create("parent_id" => 20)
assert_equal 4, new4.pos
new4.insert_at(3)
assert_equal 3, new4.pos
new.reload
assert_equal 4, new.pos
new.insert_at(2)
assert_equal 2, new.pos
new4.reload
assert_equal 4, new4.pos
new5 = ListMixinSub1.create("parent_id" => 20)
assert_equal 5, new5.pos
new5.insert_at(1)
assert_equal 1, new5.pos
new4.reload
assert_equal 5, new4.pos
end
def test_delete_middle
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(2).destroy
assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(1).pos
assert_equal 2, ListMixin.find(3).pos
assert_equal 3, ListMixin.find(4).pos
ListMixin.find(1).destroy
assert_equal [3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(3).pos
assert_equal 2, ListMixin.find(4).pos
end
end

View file

@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_searchable'
ActiveRecord::Base.send(:include, Redmine::Acts::Searchable)

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 Acts
module Searchable
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
# Adds the search methods to the class.
#
# Options:
# * :columns - a column or an array of columns to search
# * :project_key - project foreign key (default to project_id)
# * :date_column - name of the datetime column used to sort results (default to :created_on)
# * :permission - permission required to search the model
# * :scope - scope used to search results
# * :preload - associations to preload when loading results for display
def acts_as_searchable(options = {})
return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
options.assert_valid_keys(:columns, :project_key, :date_column, :permission, :scope, :preload)
cattr_accessor :searchable_options
self.searchable_options = options
if searchable_options[:columns].nil?
raise 'No searchable column defined.'
elsif !searchable_options[:columns].is_a?(Array)
searchable_options[:columns] = [] << searchable_options[:columns]
end
searchable_options[:project_key] ||= "#{table_name}.project_id"
searchable_options[:date_column] ||= :created_on
# Should we search additional associations on this model ?
searchable_options[:search_custom_fields] = reflect_on_association(:custom_values).present?
searchable_options[:search_attachments] = reflect_on_association(:attachments).present?
searchable_options[:search_journals] = reflect_on_association(:journals).present?
send :include, Redmine::Acts::Searchable::InstanceMethods
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
# Searches the model for the given tokens and user visibility.
# The projects argument can be either nil (will search all projects), a project or an array of projects.
# Returns an array that contains the rank and id of all results.
# In current implementation, the rank is the record timestamp converted as an integer.
#
# Valid options:
# * :titles_only - searches tokens in the first searchable column only
# * :all_words - searches results that match all token
# * :
# * :limit - maximum number of results to return
#
# Example:
# Issue.search_result_ranks_and_ids("foo")
# # => [[1419595329, 69], [1419595622, 123]]
def search_result_ranks_and_ids(tokens, user=User.current, projects=nil, options={})
tokens = [] << tokens unless tokens.is_a?(Array)
projects = [] << projects if projects.is_a?(Project)
columns = searchable_options[:columns]
columns = columns[0..0] if options[:titles_only]
r = []
queries = 0
unless options[:attachments] == 'only'
r = fetch_ranks_and_ids(
search_scope(user, projects, options).
where(search_tokens_condition(columns, tokens, options[:all_words])),
options[:limit]
)
queries += 1
if !options[:titles_only] && searchable_options[:search_custom_fields]
searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true).to_a
if searchable_custom_fields.any?
fields_by_visibility = searchable_custom_fields.group_by {|field|
field.visibility_by_project_condition(searchable_options[:project_key], user, "#{CustomValue.table_name}.custom_field_id")
}
clauses = []
fields_by_visibility.each do |visibility, fields|
clauses << "(#{CustomValue.table_name}.custom_field_id IN (#{fields.map(&:id).join(',')}) AND (#{visibility}))"
end
visibility = clauses.join(' OR ')
r |= fetch_ranks_and_ids(
search_scope(user, projects, options).
joins(:custom_values).
where(visibility).
where(search_tokens_condition(["#{CustomValue.table_name}.value"], tokens, options[:all_words])),
options[:limit]
)
queries += 1
end
end
if !options[:titles_only] && searchable_options[:search_journals]
r |= fetch_ranks_and_ids(
search_scope(user, projects, options).
joins(:journals).
where("#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes)})", false).
where(search_tokens_condition(["#{Journal.table_name}.notes"], tokens, options[:all_words])),
options[:limit]
)
queries += 1
end
end
if searchable_options[:search_attachments] && (options[:titles_only] ? options[:attachments] == 'only' : options[:attachments] != '0')
r |= fetch_ranks_and_ids(
search_scope(user, projects, options).
joins(:attachments).
where(search_tokens_condition(["#{Attachment.table_name}.filename", "#{Attachment.table_name}.description"], tokens, options[:all_words])),
options[:limit]
)
queries += 1
end
if queries > 1
r = r.sort.reverse
if options[:limit] && r.size > options[:limit]
r = r[0, options[:limit]]
end
end
r
end
def search_tokens_condition(columns, tokens, all_words)
token_clauses = columns.map {|column| "(#{search_token_match_statement(column)})"}
sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(all_words ? ' AND ' : ' OR ')
[sql, * (tokens.collect {|w| "%#{w}%"} * token_clauses.size).sort]
end
private :search_tokens_condition
def search_token_match_statement(column, value='?')
Redmine::Database.like(column, value)
end
private :search_token_match_statement
def fetch_ranks_and_ids(scope, limit)
scope.
reorder(searchable_options[:date_column] => :desc, :id => :desc).
limit(limit).
distinct.
pluck(searchable_options[:date_column], :id).
# converts timestamps to integers for faster sort
map {|timestamp, id| [timestamp.to_i, id]}
end
private :fetch_ranks_and_ids
# Returns the search scope for user and projects
def search_scope(user, projects, options={})
if projects.is_a?(Array) && projects.empty?
# no results
return none
end
scope = (searchable_options[:scope] || self)
if scope.is_a? Proc
scope = scope.call(options)
end
if respond_to?(:visible) && !searchable_options.has_key?(:permission)
scope = scope.visible(user)
else
permission = searchable_options[:permission] || :view_project
scope = scope.where(Project.allowed_to_condition(user, permission))
end
if projects
scope = scope.where("#{searchable_options[:project_key]} IN (?)", projects.map(&:id))
end
scope
end
private :search_scope
# Returns search results of given ids
def search_results_from_ids(ids)
where(:id => ids).preload(searchable_options[:preload]).to_a
end
# Returns search results with same arguments as search_result_ranks_and_ids
def search_results(*args)
ranks_and_ids = search_result_ranks_and_ids(*args)
search_results_from_ids(ranks_and_ids.map(&:last))
end
end
end
end
end
end

View file

@ -0,0 +1,26 @@
acts_as_tree
============
Specify this +acts_as+ extension if you want to model a tree structure by providing a parent association and a children
association. This requires that you have a foreign key column, which by default is called +parent_id+.
class Category < ActiveRecord::Base
acts_as_tree :order => "name"
end
Example:
root
\_ child1
\_ subchild1
\_ subchild2
root = Category.create("name" => "root")
child1 = root.children.create("name" => "child1")
subchild1 = child1.children.create("name" => "subchild1")
root.parent # => nil
child1.parent # => root
root.children # => [child1]
root.children.first.children.first # => subchild1
Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license

View file

@ -0,0 +1,22 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test acts_as_tree plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for acts_as_tree plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'acts_as_tree'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end

View file

@ -0,0 +1 @@
ActiveRecord::Base.send :include, ActiveRecord::Acts::Tree

View file

@ -0,0 +1,109 @@
module ActiveRecord
module Acts
module Tree
def self.included(base)
base.extend(ClassMethods)
end
# Specify this +acts_as+ extension if you want to model a tree structure by providing a parent association and a children
# association. This requires that you have a foreign key column, which by default is called +parent_id+.
#
# class Category < ActiveRecord::Base
# acts_as_tree :order => "name"
# end
#
# Example:
# root
# \_ child1
# \_ subchild1
# \_ subchild2
#
# root = Category.create("name" => "root")
# child1 = root.children.create("name" => "child1")
# subchild1 = child1.children.create("name" => "subchild1")
#
# root.parent # => nil
# child1.parent # => root
# root.children # => [child1]
# root.children.first.children.first # => subchild1
#
# In addition to the parent and children associations, the following instance methods are added to the class
# after calling <tt>acts_as_tree</tt>:
# * <tt>siblings</tt> - Returns all the children of the parent, excluding the current node (<tt>[subchild2]</tt> when called on <tt>subchild1</tt>)
# * <tt>self_and_siblings</tt> - Returns all the children of the parent, including the current node (<tt>[subchild1, subchild2]</tt> when called on <tt>subchild1</tt>)
# * <tt>ancestors</tt> - Returns all the ancestors of the current node (<tt>[child1, root]</tt> when called on <tt>subchild2</tt>)
# * <tt>root</tt> - Returns the root of the current node (<tt>root</tt> when called on <tt>subchild2</tt>)
module ClassMethods
# Configuration options are:
#
# * <tt>foreign_key</tt> - specifies the column name to use for tracking of the tree (default: +parent_id+)
# * <tt>order</tt> - makes it possible to sort the children according to this SQL snippet.
# * <tt>counter_cache</tt> - keeps a count in a +children_count+ column if set to +true+ (default: +false+).
def acts_as_tree(options = {})
configuration = { :foreign_key => "parent_id", :dependent => :destroy, :order => nil, :counter_cache => nil }
configuration.update(options) if options.is_a?(Hash)
belongs_to :parent, :class_name => name, :foreign_key => configuration[:foreign_key], :counter_cache => configuration[:counter_cache]
has_many :children, lambda {order(configuration[:order])}, :class_name => name, :foreign_key => configuration[:foreign_key], :dependent => configuration[:dependent]
scope :roots, lambda {
where("#{configuration[:foreign_key]} IS NULL").
order(configuration[:order])
}
send :include, ActiveRecord::Acts::Tree::InstanceMethods
end
end
module InstanceMethods
# Returns list of ancestors, starting from parent until root.
#
# subchild1.ancestors # => [child1, root]
def ancestors
node, nodes = self, []
nodes << node = node.parent while node.parent
nodes
end
# Returns list of descendants.
#
# root.descendants # => [child1, subchild1, subchild2]
def descendants(depth=nil)
depth ||= 0
result = children.dup
unless depth == 1
result += children.collect {|child| child.descendants(depth-1)}.flatten
end
result
end
# Returns list of descendants and a reference to the current node.
#
# root.self_and_descendants # => [root, child1, subchild1, subchild2]
def self_and_descendants(depth=nil)
[self] + descendants(depth)
end
# Returns the root node of the tree.
def root
node = self
node = node.parent while node.parent
node
end
# Returns all siblings of the current node.
#
# subchild1.siblings # => [subchild2]
def siblings
self_and_siblings - [self]
end
# Returns all siblings and a reference to the current node.
#
# subchild1.self_and_siblings # => [subchild1, subchild2]
def self_and_siblings
parent ? parent.children : self.class.roots
end
end
end
end
end

View file

@ -0,0 +1,219 @@
require 'test/unit'
require 'rubygems'
require 'active_record'
$:.unshift File.dirname(__FILE__) + '/../lib'
require File.dirname(__FILE__) + '/../init'
class Test::Unit::TestCase
def assert_queries(num = 1)
$query_count = 0
yield
ensure
assert_equal num, $query_count, "#{$query_count} instead of #{num} queries were executed."
end
def assert_no_queries(&block)
assert_queries(0, &block)
end
end
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")
# AR keeps printing annoying schema statements
$stdout = StringIO.new
def setup_db
ActiveRecord::Base.logger
ActiveRecord::Schema.define(:version => 1) do
create_table :mixins do |t|
t.column :type, :string
t.column :parent_id, :integer
end
end
end
def teardown_db
ActiveRecord::Base.connection.tables.each do |table|
ActiveRecord::Base.connection.drop_table(table)
end
end
class Mixin < ActiveRecord::Base
end
class TreeMixin < Mixin
acts_as_tree :foreign_key => "parent_id", :order => "id"
end
class TreeMixinWithoutOrder < Mixin
acts_as_tree :foreign_key => "parent_id"
end
class RecursivelyCascadedTreeMixin < Mixin
acts_as_tree :foreign_key => "parent_id"
has_one :first_child, :class_name => 'RecursivelyCascadedTreeMixin', :foreign_key => :parent_id
end
class TreeTest < Test::Unit::TestCase
def setup
setup_db
@root1 = TreeMixin.create!
@root_child1 = TreeMixin.create! :parent_id => @root1.id
@child1_child = TreeMixin.create! :parent_id => @root_child1.id
@root_child2 = TreeMixin.create! :parent_id => @root1.id
@root2 = TreeMixin.create!
@root3 = TreeMixin.create!
end
def teardown
teardown_db
end
def test_children
assert_equal @root1.children, [@root_child1, @root_child2]
assert_equal @root_child1.children, [@child1_child]
assert_equal @child1_child.children, []
assert_equal @root_child2.children, []
end
def test_parent
assert_equal @root_child1.parent, @root1
assert_equal @root_child1.parent, @root_child2.parent
assert_nil @root1.parent
end
def test_delete
assert_equal 6, TreeMixin.count
@root1.destroy
assert_equal 2, TreeMixin.count
@root2.destroy
@root3.destroy
assert_equal 0, TreeMixin.count
end
def test_insert
@extra = @root1.children.create
assert @extra
assert_equal @extra.parent, @root1
assert_equal 3, @root1.children.size
assert @root1.children.include?(@extra)
assert @root1.children.include?(@root_child1)
assert @root1.children.include?(@root_child2)
end
def test_ancestors
assert_equal [], @root1.ancestors
assert_equal [@root1], @root_child1.ancestors
assert_equal [@root_child1, @root1], @child1_child.ancestors
assert_equal [@root1], @root_child2.ancestors
assert_equal [], @root2.ancestors
assert_equal [], @root3.ancestors
end
def test_root
assert_equal @root1, TreeMixin.root
assert_equal @root1, @root1.root
assert_equal @root1, @root_child1.root
assert_equal @root1, @child1_child.root
assert_equal @root1, @root_child2.root
assert_equal @root2, @root2.root
assert_equal @root3, @root3.root
end
def test_roots
assert_equal [@root1, @root2, @root3], TreeMixin.roots
end
def test_siblings
assert_equal [@root2, @root3], @root1.siblings
assert_equal [@root_child2], @root_child1.siblings
assert_equal [], @child1_child.siblings
assert_equal [@root_child1], @root_child2.siblings
assert_equal [@root1, @root3], @root2.siblings
assert_equal [@root1, @root2], @root3.siblings
end
def test_self_and_siblings
assert_equal [@root1, @root2, @root3], @root1.self_and_siblings
assert_equal [@root_child1, @root_child2], @root_child1.self_and_siblings
assert_equal [@child1_child], @child1_child.self_and_siblings
assert_equal [@root_child1, @root_child2], @root_child2.self_and_siblings
assert_equal [@root1, @root2, @root3], @root2.self_and_siblings
assert_equal [@root1, @root2, @root3], @root3.self_and_siblings
end
end
class TreeTestWithEagerLoading < Test::Unit::TestCase
def setup
teardown_db
setup_db
@root1 = TreeMixin.create!
@root_child1 = TreeMixin.create! :parent_id => @root1.id
@child1_child = TreeMixin.create! :parent_id => @root_child1.id
@root_child2 = TreeMixin.create! :parent_id => @root1.id
@root2 = TreeMixin.create!
@root3 = TreeMixin.create!
@rc1 = RecursivelyCascadedTreeMixin.create!
@rc2 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc1.id
@rc3 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc2.id
@rc4 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc3.id
end
def teardown
teardown_db
end
def test_eager_association_loading
roots = TreeMixin.find(:all, :include => :children, :conditions => "mixins.parent_id IS NULL", :order => "mixins.id")
assert_equal [@root1, @root2, @root3], roots
assert_no_queries do
assert_equal 2, roots[0].children.size
assert_equal 0, roots[1].children.size
assert_equal 0, roots[2].children.size
end
end
def test_eager_association_loading_with_recursive_cascading_three_levels_has_many
root_node = RecursivelyCascadedTreeMixin.find(:first, :include => { :children => { :children => :children } }, :order => 'mixins.id')
assert_equal @rc4, assert_no_queries { root_node.children.first.children.first.children.first }
end
def test_eager_association_loading_with_recursive_cascading_three_levels_has_one
root_node = RecursivelyCascadedTreeMixin.find(:first, :include => { :first_child => { :first_child => :first_child } }, :order => 'mixins.id')
assert_equal @rc4, assert_no_queries { root_node.first_child.first_child.first_child }
end
def test_eager_association_loading_with_recursive_cascading_three_levels_belongs_to
leaf_node = RecursivelyCascadedTreeMixin.find(:first, :include => { :parent => { :parent => :parent } }, :order => 'mixins.id DESC')
assert_equal @rc1, assert_no_queries { leaf_node.parent.parent.parent }
end
end
class TreeTestWithoutOrder < Test::Unit::TestCase
def setup
setup_db
@root1 = TreeMixinWithoutOrder.create!
@root2 = TreeMixinWithoutOrder.create!
end
def teardown
teardown_db
end
def test_root
assert [@root1, @root2].include?(TreeMixinWithoutOrder.root)
end
def test_roots
assert_equal [], [@root1, @root2] - TreeMixinWithoutOrder.roots
end
end

View file

View file

View file

View file

@ -0,0 +1,74 @@
*SVN* (version numbers are overrated)
* (5 Oct 2006) Allow customization of #versions association options [Dan Peterson]
*0.5.1*
* (8 Aug 2006) Versioned models now belong to the unversioned model. @article_version.article.class => Article [Aslak Hellesoy]
*0.5* # do versions even matter for plugins?
* (21 Apr 2006) Added without_locking and without_revision methods.
Foo.without_revision do
@foo.update_attributes ...
end
*0.4*
* (28 March 2006) Rename non_versioned_fields to non_versioned_columns (old one is kept for compatibility).
* (28 March 2006) Made explicit documentation note that string column names are required for non_versioned_columns.
*0.3.1*
* (7 Jan 2006) explicitly set :foreign_key option for the versioned model's belongs_to assocation for STI [Caged]
* (7 Jan 2006) added tests to prove has_many :through joins work
*0.3*
* (2 Jan 2006) added ability to share a mixin with versioned class
* (2 Jan 2006) changed the dynamic version model to MyModel::Version
*0.2.4*
* (27 Nov 2005) added note about possible destructive behavior of if_changed? [Michael Schuerig]
*0.2.3*
* (12 Nov 2005) fixed bug with old behavior of #blank? [Michael Schuerig]
* (12 Nov 2005) updated tests to use ActiveRecord Schema
*0.2.2*
* (3 Nov 2005) added documentation note to #acts_as_versioned [Martin Jul]
*0.2.1*
* (6 Oct 2005) renamed dirty? to changed? to keep it uniform. it was aliased to keep it backwards compatible.
*0.2*
* (6 Oct 2005) added find_versions and find_version class methods.
* (6 Oct 2005) removed transaction from create_versioned_table().
this way you can specify your own transaction around a group of operations.
* (30 Sep 2005) fixed bug where find_versions() would order by 'version' twice. (found by Joe Clark)
* (26 Sep 2005) added :sequence_name option to acts_as_versioned to set the sequence name on the versioned model
*0.1.3* (18 Sep 2005)
* First RubyForge release
*0.1.2*
* check if module is already included when acts_as_versioned is called
*0.1.1*
* Adding tests and rdocs
*0.1*
* Initial transfer from Rails ticket: http://dev.rubyonrails.com/ticket/1974

View file

@ -0,0 +1,20 @@
Copyright (c) 2005 Rick Olson
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,28 @@
= acts_as_versioned
This library adds simple versioning to an ActiveRecord module. ActiveRecord is required.
== Resources
Install
* gem install acts_as_versioned
Rubyforge project
* http://rubyforge.org/projects/ar-versioned
RDocs
* http://ar-versioned.rubyforge.org
Subversion
* http://techno-weenie.net/svn/projects/acts_as_versioned
Collaboa
* http://collaboa.techno-weenie.net/repository/browse/acts_as_versioned
Special thanks to Dreamer on ##rubyonrails for help in early testing. His ServerSideWiki (http://serversidewiki.com)
was the first project to use acts_as_versioned <em>in the wild</em>.

View file

@ -0,0 +1,41 @@
== Creating the test database
The default name for the test databases is "activerecord_versioned". If you
want to use another database name then be sure to update the connection
adapter setups you want to test with in test/connections/<your database>/connection.rb.
When you have the database online, you can import the fixture tables with
the test/fixtures/db_definitions/*.sql files.
Make sure that you create database objects with the same user that you specified in i
connection.rb otherwise (on Postgres, at least) tests for default values will fail.
== Running with Rake
The easiest way to run the unit tests is through Rake. The default task runs
the entire test suite for all the adapters. You can also run the suite on just
one adapter by using the tasks test_mysql_ruby, test_ruby_mysql, test_sqlite,
or test_postresql. For more information, checkout the full array of rake tasks with "rake -T"
Rake can be found at http://rake.rubyforge.org
== Running by hand
Unit tests are located in test directory. If you only want to run a single test suite,
or don't want to bother with Rake, you can do so with something like:
cd test; ruby -I "connections/native_mysql" base_test.rb
That'll run the base suite using the MySQL-Ruby adapter. Change the adapter
and test suite name as needed.
== Faster tests
If you are using a database that supports transactions, you can set the
"AR_TX_FIXTURES" environment variable to "yes" to use transactional fixtures.
This gives a very large speed boost. With rake:
rake AR_TX_FIXTURES=yes
Or, by hand:
AR_TX_FIXTURES=yes ruby -I connections/native_sqlite3 base_test.rb

View file

@ -0,0 +1,182 @@
require 'rubygems'
Gem::manage_gems
require 'rake/rdoctask'
require 'rake/packagetask'
require 'rake/gempackagetask'
require 'rake/testtask'
require 'rake/contrib/rubyforgepublisher'
PKG_NAME = 'acts_as_versioned'
PKG_VERSION = '0.3.1'
PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}"
PROD_HOST = "technoweenie@bidwell.textdrive.com"
RUBY_FORGE_PROJECT = 'ar-versioned'
RUBY_FORGE_USER = 'technoweenie'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test the calculations plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for the calculations plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = "#{PKG_NAME} -- Simple versioning with active record models"
rdoc.options << '--line-numbers --inline-source'
rdoc.rdoc_files.include('README', 'CHANGELOG', 'RUNNING_UNIT_TESTS')
rdoc.rdoc_files.include('lib/**/*.rb')
end
spec = Gem::Specification.new do |s|
s.name = PKG_NAME
s.version = PKG_VERSION
s.platform = Gem::Platform::RUBY
s.summary = "Simple versioning with active record models"
s.files = FileList["{lib,test}/**/*"].to_a + %w(README MIT-LICENSE CHANGELOG RUNNING_UNIT_TESTS)
s.files.delete "acts_as_versioned_plugin.sqlite.db"
s.files.delete "acts_as_versioned_plugin.sqlite3.db"
s.files.delete "test/debug.log"
s.require_path = 'lib'
s.autorequire = 'acts_as_versioned'
s.has_rdoc = true
s.test_files = Dir['test/**/*_test.rb']
s.add_dependency 'activerecord', '>= 1.10.1'
s.add_dependency 'activesupport', '>= 1.1.1'
s.author = "Rick Olson"
s.email = "technoweenie@gmail.com"
s.homepage = "http://techno-weenie.net"
end
Rake::GemPackageTask.new(spec) do |pkg|
pkg.need_tar = true
end
desc "Publish the API documentation"
task :pdoc => [:rdoc] do
Rake::RubyForgePublisher.new(RUBY_FORGE_PROJECT, RUBY_FORGE_USER).upload
end
desc 'Publish the gem and API docs'
task :publish => [:pdoc, :rubyforge_upload]
desc "Publish the release files to RubyForge."
task :rubyforge_upload => :package do
files = %w(gem tgz).map { |ext| "pkg/#{PKG_FILE_NAME}.#{ext}" }
if RUBY_FORGE_PROJECT then
require 'net/http'
require 'open-uri'
project_uri = "http://rubyforge.org/projects/#{RUBY_FORGE_PROJECT}/"
project_data = open(project_uri) { |data| data.read }
group_id = project_data[/[?&]group_id=(\d+)/, 1]
raise "Couldn't get group id" unless group_id
# This echos password to shell which is a bit sucky
if ENV["RUBY_FORGE_PASSWORD"]
password = ENV["RUBY_FORGE_PASSWORD"]
else
print "#{RUBY_FORGE_USER}@rubyforge.org's password: "
password = STDIN.gets.chomp
end
login_response = Net::HTTP.start("rubyforge.org", 80) do |http|
data = [
"login=1",
"form_loginname=#{RUBY_FORGE_USER}",
"form_pw=#{password}"
].join("&")
http.post("/account/login.php", data)
end
cookie = login_response["set-cookie"]
raise "Login failed" unless cookie
headers = { "Cookie" => cookie }
release_uri = "http://rubyforge.org/frs/admin/?group_id=#{group_id}"
release_data = open(release_uri, headers) { |data| data.read }
package_id = release_data[/[?&]package_id=(\d+)/, 1]
raise "Couldn't get package id" unless package_id
first_file = true
release_id = ""
files.each do |filename|
basename = File.basename(filename)
file_ext = File.extname(filename)
file_data = File.open(filename, "rb") { |file| file.read }
puts "Releasing #{basename}..."
release_response = Net::HTTP.start("rubyforge.org", 80) do |http|
release_date = Time.now.strftime("%Y-%m-%d %H:%M")
type_map = {
".zip" => "3000",
".tgz" => "3110",
".gz" => "3110",
".gem" => "1400"
}; type_map.default = "9999"
type = type_map[file_ext]
boundary = "rubyqMY6QN9bp6e4kS21H4y0zxcvoor"
query_hash = if first_file then
{
"group_id" => group_id,
"package_id" => package_id,
"release_name" => PKG_FILE_NAME,
"release_date" => release_date,
"type_id" => type,
"processor_id" => "8000", # Any
"release_notes" => "",
"release_changes" => "",
"preformatted" => "1",
"submit" => "1"
}
else
{
"group_id" => group_id,
"release_id" => release_id,
"package_id" => package_id,
"step2" => "1",
"type_id" => type,
"processor_id" => "8000", # Any
"submit" => "Add This File"
}
end
query = "?" + query_hash.map do |(name, value)|
[name, URI.encode(value)].join("=")
end.join("&")
data = [
"--" + boundary,
"Content-Disposition: form-data; name=\"userfile\"; filename=\"#{basename}\"",
"Content-Type: application/octet-stream",
"Content-Transfer-Encoding: binary",
"", file_data, ""
].join("\x0D\x0A")
release_headers = headers.merge(
"Content-Type" => "multipart/form-data; boundary=#{boundary}"
)
target = first_file ? "/frs/admin/qrs.php" : "/frs/admin/editrelease.php"
http.post(target + query, data, release_headers)
end
if first_file then
release_id = release_response.body[/release_id=(\d+)/, 1]
raise("Couldn't get release id") unless release_id
end
first_file = false
end
end
end

View file

@ -0,0 +1 @@
require 'acts_as_versioned'

View file

@ -0,0 +1,569 @@
# Copyright (c) 2005 Rick Olson
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
module ActiveRecord #:nodoc:
module Acts #:nodoc:
# Specify this act if you want to save a copy of the row in a versioned table. This assumes there is a
# versioned table ready and that your model has a version field. This works with optimistic locking if the lock_version
# column is present as well.
#
# The class for the versioned model is derived the first time it is seen. Therefore, if you change your database schema you have to restart
# your container for the changes to be reflected. In development mode this usually means restarting WEBrick.
#
# class Page < ActiveRecord::Base
# # assumes pages_versions table
# acts_as_versioned
# end
#
# Example:
#
# page = Page.create(:title => 'hello world!')
# page.version # => 1
#
# page.title = 'hello world'
# page.save
# page.version # => 2
# page.versions.size # => 2
#
# page.revert_to(1) # using version number
# page.title # => 'hello world!'
#
# page.revert_to(page.versions.last) # using versioned instance
# page.title # => 'hello world'
#
# page.versions.earliest # efficient query to find the first version
# page.versions.latest # efficient query to find the most recently created version
#
#
# Simple Queries to page between versions
#
# page.versions.before(version)
# page.versions.after(version)
#
# Access the previous/next versions from the versioned model itself
#
# version = page.versions.latest
# version.previous # go back one version
# version.next # go forward one version
#
# See ActiveRecord::Acts::Versioned::ClassMethods#acts_as_versioned for configuration options
module Versioned
CALLBACKS = [:set_new_version, :save_version_on_create, :save_version?, :clear_altered_attributes]
def self.included(base) # :nodoc:
base.extend ClassMethods
end
module ClassMethods
# == Configuration options
#
# * <tt>class_name</tt> - versioned model class name (default: PageVersion in the above example)
# * <tt>table_name</tt> - versioned model table name (default: page_versions in the above example)
# * <tt>foreign_key</tt> - foreign key used to relate the versioned model to the original model (default: page_id in the above example)
# * <tt>inheritance_column</tt> - name of the column to save the model's inheritance_column value for STI. (default: versioned_type)
# * <tt>version_column</tt> - name of the column in the model that keeps the version number (default: version)
# * <tt>sequence_name</tt> - name of the custom sequence to be used by the versioned model.
# * <tt>limit</tt> - number of revisions to keep, defaults to unlimited
# * <tt>if</tt> - symbol of method to check before saving a new version. If this method returns false, a new version is not saved.
# For finer control, pass either a Proc or modify Model#version_condition_met?
#
# acts_as_versioned :if => Proc.new { |auction| !auction.expired? }
#
# or...
#
# class Auction
# def version_condition_met? # totally bypasses the <tt>:if</tt> option
# !expired?
# end
# end
#
# * <tt>if_changed</tt> - Simple way of specifying attributes that are required to be changed before saving a model. This takes
# either a symbol or array of symbols. WARNING - This will attempt to overwrite any attribute setters you may have.
# Use this instead if you want to write your own attribute setters (and ignore if_changed):
#
# def name=(new_name)
# write_changed_attribute :name, new_name
# end
#
# * <tt>extend</tt> - Lets you specify a module to be mixed in both the original and versioned models. You can also just pass a block
# to create an anonymous mixin:
#
# class Auction
# acts_as_versioned do
# def started?
# !started_at.nil?
# end
# end
# end
#
# or...
#
# module AuctionExtension
# def started?
# !started_at.nil?
# end
# end
# class Auction
# acts_as_versioned :extend => AuctionExtension
# end
#
# Example code:
#
# @auction = Auction.find(1)
# @auction.started?
# @auction.versions.first.started?
#
# == Database Schema
#
# The model that you're versioning needs to have a 'version' attribute. The model is versioned
# into a table called #{model}_versions where the model name is singlular. The _versions table should
# contain all the fields you want versioned, the same version column, and a #{model}_id foreign key field.
#
# A lock_version field is also accepted if your model uses Optimistic Locking. If your table uses Single Table inheritance,
# then that field is reflected in the versioned model as 'versioned_type' by default.
#
# Acts_as_versioned comes prepared with the ActiveRecord::Acts::Versioned::ActMethods::ClassMethods#create_versioned_table
# method, perfect for a migration. It will also create the version column if the main model does not already have it.
#
# class AddVersions < ActiveRecord::Migration
# def self.up
# # create_versioned_table takes the same options hash
# # that create_table does
# Post.create_versioned_table
# end
#
# def self.down
# Post.drop_versioned_table
# end
# end
#
# == Changing What Fields Are Versioned
#
# By default, acts_as_versioned will version all but these fields:
#
# [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column]
#
# You can add or change those by modifying #non_versioned_columns. Note that this takes strings and not symbols.
#
# class Post < ActiveRecord::Base
# acts_as_versioned
# self.non_versioned_columns << 'comments_count'
# end
#
def acts_as_versioned(options = {}, &extension)
# don't allow multiple calls
return if self.included_modules.include?(ActiveRecord::Acts::Versioned::ActMethods)
send :include, ActiveRecord::Acts::Versioned::ActMethods
cattr_accessor :versioned_class_name, :versioned_foreign_key, :versioned_table_name, :versioned_inheritance_column,
:version_column, :max_version_limit, :track_altered_attributes, :version_condition, :version_sequence_name, :non_versioned_columns,
:version_association_options
# legacy
alias_method :non_versioned_fields, :non_versioned_columns
alias_method :non_versioned_fields=, :non_versioned_columns=
class << self
alias_method :non_versioned_fields, :non_versioned_columns
alias_method :non_versioned_fields=, :non_versioned_columns=
end
send :attr_accessor, :altered_attributes
self.versioned_class_name = options[:class_name] || "Version"
self.versioned_foreign_key = options[:foreign_key] || self.to_s.foreign_key
self.versioned_table_name = options[:table_name] || "#{table_name_prefix}#{base_class.name.demodulize.underscore}_versions#{table_name_suffix}"
self.versioned_inheritance_column = options[:inheritance_column] || "versioned_#{inheritance_column}"
self.version_column = options[:version_column] || 'version'
self.version_sequence_name = options[:sequence_name]
self.max_version_limit = options[:limit].to_i
self.version_condition = options[:if] || true
self.non_versioned_columns = [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column]
self.version_association_options = {
:class_name => "#{self.to_s}::#{versioned_class_name}",
:foreign_key => versioned_foreign_key,
:dependent => :delete_all
}.merge(options[:association_options] || {})
if block_given?
extension_module_name = "#{versioned_class_name}Extension"
silence_warnings do
self.const_set(extension_module_name, Module.new(&extension))
end
options[:extend] = self.const_get(extension_module_name)
end
class_eval do
has_many :versions, version_association_options do
# finds earliest version of this record
def earliest
@earliest ||= order('version').first
end
# find latest version of this record
def latest
@latest ||= order('version desc').first
end
end
before_save :set_new_version
after_create :save_version_on_create
after_update :save_version
after_save :clear_old_versions
after_save :clear_altered_attributes
unless options[:if_changed].nil?
self.track_altered_attributes = true
options[:if_changed] = [options[:if_changed]] unless options[:if_changed].is_a?(Array)
options[:if_changed].each do |attr_name|
define_method("#{attr_name}=") do |value|
write_changed_attribute attr_name, value
end
end
end
include options[:extend] if options[:extend].is_a?(Module)
end
# create the dynamic versioned model
const_set(versioned_class_name, Class.new(ActiveRecord::Base)).class_eval do
def self.reloadable? ; false ; end
# find first version before the given version
def self.before(version)
order('version desc').
where("#{original_class.versioned_foreign_key} = ? and version < ?", version.send(original_class.versioned_foreign_key), version.version).
first
end
# find first version after the given version.
def self.after(version)
order('version').
where("#{original_class.versioned_foreign_key} = ? and version > ?", version.send(original_class.versioned_foreign_key), version.version).
first
end
def previous
self.class.before(self)
end
def next
self.class.after(self)
end
def versions_count
page.version
end
end
versioned_class.cattr_accessor :original_class
versioned_class.original_class = self
versioned_class.table_name = versioned_table_name
versioned_class.belongs_to self.to_s.demodulize.underscore.to_sym,
:class_name => "::#{self.to_s}",
:foreign_key => versioned_foreign_key
versioned_class.send :include, options[:extend] if options[:extend].is_a?(Module)
versioned_class.set_sequence_name version_sequence_name if version_sequence_name
end
end
module ActMethods
def self.included(base) # :nodoc:
base.extend ClassMethods
end
# Finds a specific version of this record
def find_version(version = nil)
self.class.find_version(id, version)
end
# Saves a version of the model if applicable
def save_version
save_version_on_create if save_version?
end
# Saves a version of the model in the versioned table. This is called in the after_save callback by default
def save_version_on_create
rev = self.class.versioned_class.new
self.clone_versioned_model(self, rev)
rev.version = send(self.class.version_column)
rev.send("#{self.class.versioned_foreign_key}=", self.id)
rev.save
end
# Clears old revisions if a limit is set with the :limit option in <tt>acts_as_versioned</tt>.
# Override this method to set your own criteria for clearing old versions.
def clear_old_versions
return if self.class.max_version_limit == 0
excess_baggage = send(self.class.version_column).to_i - self.class.max_version_limit
if excess_baggage > 0
sql = "DELETE FROM #{self.class.versioned_table_name} WHERE version <= #{excess_baggage} AND #{self.class.versioned_foreign_key} = #{self.id}"
self.class.versioned_class.connection.execute sql
end
end
def versions_count
version
end
# Reverts a model to a given version. Takes either a version number or an instance of the versioned model
def revert_to(version)
if version.is_a?(self.class.versioned_class)
return false unless version.send(self.class.versioned_foreign_key) == self.id and !version.new_record?
else
return false unless version = versions.find_by_version(version)
end
self.clone_versioned_model(version, self)
self.send("#{self.class.version_column}=", version.version)
true
end
# Reverts a model to a given version and saves the model.
# Takes either a version number or an instance of the versioned model
def revert_to!(version)
revert_to(version) ? save_without_revision : false
end
# Temporarily turns off Optimistic Locking while saving. Used when reverting so that a new version is not created.
def save_without_revision
save_without_revision!
true
rescue
false
end
def save_without_revision!
without_locking do
without_revision do
save!
end
end
end
# Returns an array of attribute keys that are versioned. See non_versioned_columns
def versioned_attributes
self.attributes.keys.select { |k| !self.class.non_versioned_columns.include?(k) }
end
# If called with no parameters, gets whether the current model has changed and needs to be versioned.
# If called with a single parameter, gets whether the parameter has changed.
def changed?(attr_name = nil)
attr_name.nil? ?
(!self.class.track_altered_attributes || (altered_attributes && altered_attributes.length > 0)) :
(altered_attributes && altered_attributes.include?(attr_name.to_s))
end
# keep old dirty? method
alias_method :dirty?, :changed?
# Clones a model. Used when saving a new version or reverting a model's version.
def clone_versioned_model(orig_model, new_model)
self.versioned_attributes.each do |key|
new_model.send("#{key}=", orig_model.send(key)) if orig_model.respond_to?(key)
end
if self.class.columns_hash.include?(self.class.inheritance_column)
if orig_model.is_a?(self.class.versioned_class)
new_model[new_model.class.inheritance_column] = orig_model[self.class.versioned_inheritance_column]
elsif new_model.is_a?(self.class.versioned_class)
new_model[self.class.versioned_inheritance_column] = orig_model[orig_model.class.inheritance_column]
end
end
end
# Checks whether a new version shall be saved or not. Calls <tt>version_condition_met?</tt> and <tt>changed?</tt>.
def save_version?
version_condition_met? && changed?
end
# Checks condition set in the :if option to check whether a revision should be created or not. Override this for
# custom version condition checking.
def version_condition_met?
case
when version_condition.is_a?(Symbol)
send(version_condition)
when version_condition.respond_to?(:call) && (version_condition.arity == 1 || version_condition.arity == -1)
version_condition.call(self)
else
version_condition
end
end
# Executes the block with the versioning callbacks disabled.
#
# @foo.without_revision do
# @foo.save
# end
#
def without_revision(&block)
self.class.without_revision(&block)
end
# Turns off optimistic locking for the duration of the block
#
# @foo.without_locking do
# @foo.save
# end
#
def without_locking(&block)
self.class.without_locking(&block)
end
def empty_callback() end #:nodoc:
protected
# sets the new version before saving, unless you're using optimistic locking. In that case, let it take care of the version.
def set_new_version
self.send("#{self.class.version_column}=", self.next_version) if new_record? || (!locking_enabled? && save_version?)
end
# Gets the next available version for the current record, or 1 for a new record
def next_version
return 1 if new_record?
(versions.maximum('version') || 0) + 1
end
# clears current changed attributes. Called after save.
def clear_altered_attributes
self.altered_attributes = []
end
def write_changed_attribute(attr_name, attr_value)
# Convert to db type for comparison. Avoids failing Float<=>String comparisons.
attr_value_for_db = self.class.columns_hash[attr_name.to_s].type_cast_from_database(attr_value)
(self.altered_attributes ||= []) << attr_name.to_s unless self.changed?(attr_name) || self.send(attr_name) == attr_value_for_db
write_attribute(attr_name, attr_value_for_db)
end
module ClassMethods
# Finds a specific version of a specific row of this model
def find_version(id, version = nil)
return find(id) unless version
conditions = ["#{versioned_foreign_key} = ? AND version = ?", id, version]
options = { :conditions => conditions, :limit => 1 }
if result = find_versions(id, options).first
result
else
raise RecordNotFound, "Couldn't find #{name} with ID=#{id} and VERSION=#{version}"
end
end
# Finds versions of a specific model. Takes an options hash like <tt>find</tt>
def find_versions(id, options = {})
versioned_class.
where(options[:conditions] || {versioned_foreign_key => id}).
limit(options[:limit]).
order('version')
end
# Returns an array of columns that are versioned. See non_versioned_columns
def versioned_columns
self.columns.select { |c| !non_versioned_columns.include?(c.name) }
end
# Returns an instance of the dynamic versioned model
def versioned_class
const_get versioned_class_name
end
# Rake migration task to create the versioned table using options passed to acts_as_versioned
def create_versioned_table(create_table_options = {})
# create version column in main table if it does not exist
if !self.content_columns.find { |c| %w(version lock_version).include? c.name }
self.connection.add_column table_name, :version, :integer
end
self.connection.create_table(versioned_table_name, create_table_options) do |t|
t.column versioned_foreign_key, :integer
t.column :version, :integer
end
updated_col = nil
self.versioned_columns.each do |col|
updated_col = col if !updated_col && %(updated_at updated_on).include?(col.name)
self.connection.add_column versioned_table_name, col.name, col.type,
:limit => col.limit,
:default => col.default,
:scale => col.scale,
:precision => col.precision
end
if type_col = self.columns_hash[inheritance_column]
self.connection.add_column versioned_table_name, versioned_inheritance_column, type_col.type,
:limit => type_col.limit,
:default => type_col.default,
:scale => type_col.scale,
:precision => type_col.precision
end
if updated_col.nil?
self.connection.add_column versioned_table_name, :updated_at, :timestamp
end
end
# Rake migration task to drop the versioned table
def drop_versioned_table
self.connection.drop_table versioned_table_name
end
# Executes the block with the versioning callbacks disabled.
#
# Foo.without_revision do
# @foo.save
# end
#
def without_revision(&block)
class_eval do
CALLBACKS.each do |attr_name|
alias_method "orig_#{attr_name}".to_sym, attr_name
alias_method attr_name, :empty_callback
end
end
block.call
ensure
class_eval do
CALLBACKS.each do |attr_name|
alias_method attr_name, "orig_#{attr_name}".to_sym
end
end
end
# Turns off optimistic locking for the duration of the block
#
# Foo.without_locking do
# @foo.save
# end
#
def without_locking(&block)
current = ActiveRecord::Base.lock_optimistically
ActiveRecord::Base.lock_optimistically = false if current
result = block.call
ActiveRecord::Base.lock_optimistically = true if current
result
end
end
end
end
end
end
ActiveRecord::Base.send :include, ActiveRecord::Acts::Versioned

View file

@ -0,0 +1,41 @@
$:.unshift(File.dirname(__FILE__) + '/../../../rails/activesupport/lib')
$:.unshift(File.dirname(__FILE__) + '/../../../rails/activerecord/lib')
$:.unshift(File.dirname(__FILE__) + '/../lib')
require 'test/unit'
begin
require 'active_support'
require 'active_record'
require 'active_record/fixtures'
rescue LoadError
require 'rubygems'
retry
end
require 'acts_as_versioned'
config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
ActiveRecord::Base.configurations = {'test' => config[ENV['DB'] || 'sqlite3']}
ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['test'])
load(File.dirname(__FILE__) + "/schema.rb")
# set up custom sequence on widget_versions for DBs that support sequences
if ENV['DB'] == 'postgresql'
ActiveRecord::Base.connection.execute "DROP SEQUENCE widgets_seq;" rescue nil
ActiveRecord::Base.connection.remove_column :widget_versions, :id
ActiveRecord::Base.connection.execute "CREATE SEQUENCE widgets_seq START 101;"
ActiveRecord::Base.connection.execute "ALTER TABLE widget_versions ADD COLUMN id INTEGER PRIMARY KEY DEFAULT nextval('widgets_seq');"
end
Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/"
$:.unshift(Test::Unit::TestCase.fixture_path)
class Test::Unit::TestCase #:nodoc:
# Turn off transactional fixtures if you're working with MyISAM tables in MySQL
self.use_transactional_fixtures = true
# Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david)
self.use_instantiated_fixtures = false
# Add more helper methods to be used by all tests here...
end

View file

@ -0,0 +1,18 @@
sqlite:
:adapter: sqlite
:dbfile: acts_as_versioned_plugin.sqlite.db
sqlite3:
:adapter: sqlite3
:dbfile: acts_as_versioned_plugin.sqlite3.db
postgresql:
:adapter: postgresql
:username: postgres
:password: postgres
:database: acts_as_versioned_plugin_test
:min_messages: ERROR
mysql:
:adapter: mysql
:host: localhost
:username: rails
:password:
:database: acts_as_versioned_plugin_test

View file

@ -0,0 +1,6 @@
caged:
id: 1
name: caged
mly:
id: 2
name: mly

View file

@ -0,0 +1,3 @@
class Landmark < ActiveRecord::Base
acts_as_versioned :if_changed => [ :name, :longitude, :latitude ]
end

View file

@ -0,0 +1,7 @@
washington:
id: 1
landmark_id: 1
version: 1
name: Washington, D.C.
latitude: 38.895
longitude: -77.036667

View file

@ -0,0 +1,6 @@
washington:
id: 1
name: Washington, D.C.
latitude: 38.895
longitude: -77.036667
version: 1

View file

@ -0,0 +1,10 @@
welcome:
id: 1
title: Welcome to the weblog
lock_version: 24
type: LockedPage
thinking:
id: 2
title: So I was thinking
lock_version: 24
type: SpecialLockedPage

View file

@ -0,0 +1,27 @@
welcome_1:
id: 1
page_id: 1
title: Welcome to the weblg
version: 23
version_type: LockedPage
welcome_2:
id: 2
page_id: 1
title: Welcome to the weblog
version: 24
version_type: LockedPage
thinking_1:
id: 3
page_id: 2
title: So I was thinking!!!
version: 23
version_type: SpecialLockedPage
thinking_2:
id: 4
page_id: 2
title: So I was thinking
version: 24
version_type: SpecialLockedPage

View file

@ -0,0 +1,13 @@
class AddVersionedTables < ActiveRecord::Migration
def self.up
create_table("things") do |t|
t.column :title, :text
end
Thing.create_versioned_table
end
def self.down
Thing.drop_versioned_table
drop_table "things" rescue nil
end
end

View file

@ -0,0 +1,43 @@
class Page < ActiveRecord::Base
belongs_to :author
has_many :authors, :through => :versions, :order => 'name'
belongs_to :revisor, :class_name => 'Author'
has_many :revisors, :class_name => 'Author', :through => :versions, :order => 'name'
acts_as_versioned :if => :feeling_good? do
def self.included(base)
base.cattr_accessor :feeling_good
base.feeling_good = true
base.belongs_to :author
base.belongs_to :revisor, :class_name => 'Author'
end
def feeling_good?
@@feeling_good == true
end
end
end
module LockedPageExtension
def hello_world
'hello_world'
end
end
class LockedPage < ActiveRecord::Base
acts_as_versioned \
:inheritance_column => :version_type,
:foreign_key => :page_id,
:table_name => :locked_pages_revisions,
:class_name => 'LockedPageRevision',
:version_column => :lock_version,
:limit => 2,
:if_changed => :title,
:extend => LockedPageExtension
end
class SpecialLockedPage < LockedPage
end
class Author < ActiveRecord::Base
has_many :pages
end

View file

@ -0,0 +1,16 @@
welcome_2:
id: 1
page_id: 1
title: Welcome to the weblog
body: Such a lovely day
version: 24
author_id: 1
revisor_id: 1
welcome_1:
id: 2
page_id: 1
title: Welcome to the weblg
body: Such a lovely day
version: 23
author_id: 2
revisor_id: 2

View file

@ -0,0 +1,7 @@
welcome:
id: 1
title: Welcome to the weblog
body: Such a lovely day
version: 24
author_id: 1
revisor_id: 1

View file

@ -0,0 +1,6 @@
class Widget < ActiveRecord::Base
acts_as_versioned :sequence_name => 'widgets_seq', :association_options => {
:dependent => :nullify, :order => 'version desc'
}
non_versioned_columns << 'foo'
end

View file

@ -0,0 +1,46 @@
require File.join(File.dirname(__FILE__), 'abstract_unit')
if ActiveRecord::Base.connection.supports_migrations?
class Thing < ActiveRecord::Base
attr_accessor :version
acts_as_versioned
end
class MigrationTest < Test::Unit::TestCase
self.use_transactional_fixtures = false
def teardown
if ActiveRecord::Base.connection.respond_to?(:initialize_schema_information)
ActiveRecord::Base.connection.initialize_schema_information
ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0"
else
ActiveRecord::Base.connection.initialize_schema_migrations_table
ActiveRecord::Base.connection.assume_migrated_upto_version(0)
end
Thing.connection.drop_table "things" rescue nil
Thing.connection.drop_table "thing_versions" rescue nil
Thing.reset_column_information
end
def test_versioned_migration
assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' }
# take 'er up
ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
t = Thing.create :title => 'blah blah', :price => 123.45, :type => 'Thing'
assert_equal 1, t.versions.size
# check that the price column has remembered its value correctly
assert_equal t.price, t.versions.first.price
assert_equal t.title, t.versions.first.title
assert_equal t[:type], t.versions.first[:type]
# make sure that the precision of the price column has been preserved
assert_equal 7, Thing::Version.columns.find{|c| c.name == "price"}.precision
assert_equal 2, Thing::Version.columns.find{|c| c.name == "price"}.scale
# now lets take 'er back down
ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/')
assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' }
end
end
end

View file

@ -0,0 +1,68 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :pages, :force => true do |t|
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :body, :text
t.column :updated_on, :datetime
t.column :author_id, :integer
t.column :revisor_id, :integer
end
create_table :page_versions, :force => true do |t|
t.column :page_id, :integer
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :body, :text
t.column :updated_on, :datetime
t.column :author_id, :integer
t.column :revisor_id, :integer
end
create_table :authors, :force => true do |t|
t.column :page_id, :integer
t.column :name, :string
end
create_table :locked_pages, :force => true do |t|
t.column :lock_version, :integer
t.column :title, :string, :limit => 255
t.column :type, :string, :limit => 255
end
create_table :locked_pages_revisions, :force => true do |t|
t.column :page_id, :integer
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :version_type, :string, :limit => 255
t.column :updated_at, :datetime
end
create_table :widgets, :force => true do |t|
t.column :name, :string, :limit => 50
t.column :foo, :string
t.column :version, :integer
t.column :updated_at, :datetime
end
create_table :widget_versions, :force => true do |t|
t.column :widget_id, :integer
t.column :name, :string, :limit => 50
t.column :version, :integer
t.column :updated_at, :datetime
end
create_table :landmarks, :force => true do |t|
t.column :name, :string
t.column :latitude, :float
t.column :longitude, :float
t.column :version, :integer
end
create_table :landmark_versions, :force => true do |t|
t.column :landmark_id, :integer
t.column :name, :string
t.column :latitude, :float
t.column :longitude, :float
t.column :version, :integer
end
end

View file

@ -0,0 +1,347 @@
require File.join(File.dirname(__FILE__), 'abstract_unit')
require File.join(File.dirname(__FILE__), 'fixtures/page')
require File.join(File.dirname(__FILE__), 'fixtures/widget')
class VersionedTest < Test::Unit::TestCase
fixtures :pages, :page_versions, :locked_pages, :locked_pages_revisions, :authors, :landmarks, :landmark_versions
set_fixture_class :page_versions => Page::Version
def test_saves_versioned_copy
p = Page.create! :title => 'first title', :body => 'first body'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_equal 1, p.version
assert_instance_of Page.versioned_class, p.versions.first
end
def test_saves_without_revision
p = pages(:welcome)
old_versions = p.versions.count
p.save_without_revision
p.without_revision do
p.update_attributes :title => 'changed'
end
assert_equal old_versions, p.versions.count
end
def test_rollback_with_version_number
p = pages(:welcome)
assert_equal 24, p.version
assert_equal 'Welcome to the weblog', p.title
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23"
assert_equal 23, p.version
assert_equal 'Welcome to the weblg', p.title
end
def test_versioned_class_name
assert_equal 'Version', Page.versioned_class_name
assert_equal 'LockedPageRevision', LockedPage.versioned_class_name
end
def test_versioned_class
assert_equal Page::Version, Page.versioned_class
assert_equal LockedPage::LockedPageRevision, LockedPage.versioned_class
end
def test_special_methods
assert_nothing_raised { pages(:welcome).feeling_good? }
assert_nothing_raised { pages(:welcome).versions.first.feeling_good? }
assert_nothing_raised { locked_pages(:welcome).hello_world }
assert_nothing_raised { locked_pages(:welcome).versions.first.hello_world }
end
def test_rollback_with_version_class
p = pages(:welcome)
assert_equal 24, p.version
assert_equal 'Welcome to the weblog', p.title
assert p.revert_to!(p.versions.first), "Couldn't revert to 23"
assert_equal 23, p.version
assert_equal 'Welcome to the weblg', p.title
end
def test_rollback_fails_with_invalid_revision
p = locked_pages(:welcome)
assert !p.revert_to!(locked_pages(:thinking))
end
def test_saves_versioned_copy_with_options
p = LockedPage.create! :title => 'first title'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_instance_of LockedPage.versioned_class, p.versions.first
end
def test_rollback_with_version_number_with_options
p = locked_pages(:welcome)
assert_equal 'Welcome to the weblog', p.title
assert_equal 'LockedPage', p.versions.first.version_type
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23"
assert_equal 'Welcome to the weblg', p.title
assert_equal 'LockedPage', p.versions.first.version_type
end
def test_rollback_with_version_class_with_options
p = locked_pages(:welcome)
assert_equal 'Welcome to the weblog', p.title
assert_equal 'LockedPage', p.versions.first.version_type
assert p.revert_to!(p.versions.first), "Couldn't revert to 1"
assert_equal 'Welcome to the weblg', p.title
assert_equal 'LockedPage', p.versions.first.version_type
end
def test_saves_versioned_copy_with_sti
p = SpecialLockedPage.create! :title => 'first title'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_instance_of LockedPage.versioned_class, p.versions.first
assert_equal 'SpecialLockedPage', p.versions.first.version_type
end
def test_rollback_with_version_number_with_sti
p = locked_pages(:thinking)
assert_equal 'So I was thinking', p.title
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 1"
assert_equal 'So I was thinking!!!', p.title
assert_equal 'SpecialLockedPage', p.versions.first.version_type
end
def test_lock_version_works_with_versioning
p = locked_pages(:thinking)
p2 = LockedPage.find(p.id)
p.title = 'fresh title'
p.save
assert_equal 2, p.versions.size # limit!
assert_raises(ActiveRecord::StaleObjectError) do
p2.title = 'stale title'
p2.save
end
end
def test_version_if_condition
p = Page.create! :title => "title"
assert_equal 1, p.version
Page.feeling_good = false
p.save
assert_equal 1, p.version
Page.feeling_good = true
end
def test_version_if_condition2
# set new if condition
Page.class_eval do
def new_feeling_good() title[0..0] == 'a'; end
alias_method :old_feeling_good, :feeling_good?
alias_method :feeling_good?, :new_feeling_good
end
p = Page.create! :title => "title"
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'new title')
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'a title')
assert_equal 2, p.version
assert_equal 2, p.versions(true).size
# reset original if condition
Page.class_eval { alias_method :feeling_good?, :old_feeling_good }
end
def test_version_if_condition_with_block
# set new if condition
old_condition = Page.version_condition
Page.version_condition = Proc.new { |page| page.title[0..0] == 'b' }
p = Page.create! :title => "title"
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'a title')
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'b title')
assert_equal 2, p.version
assert_equal 2, p.versions(true).size
# reset original if condition
Page.version_condition = old_condition
end
def test_version_no_limit
p = Page.create! :title => "title", :body => 'first body'
p.save
p.save
5.times do |i|
assert_page_title p, i
end
end
def test_version_max_limit
p = LockedPage.create! :title => "title"
p.update_attributes(:title => "title1")
p.update_attributes(:title => "title2")
5.times do |i|
assert_page_title p, i, :lock_version
assert p.versions(true).size <= 2, "locked version can only store 2 versions"
end
end
def test_track_altered_attributes_default_value
assert !Page.track_altered_attributes
assert LockedPage.track_altered_attributes
assert SpecialLockedPage.track_altered_attributes
end
def test_version_order
assert_equal 23, pages(:welcome).versions.first.version
assert_equal 24, pages(:welcome).versions.last.version
end
def test_track_altered_attributes
p = LockedPage.create! :title => "title"
assert_equal 1, p.lock_version
assert_equal 1, p.versions(true).size
p.title = 'title'
assert !p.save_version?
p.save
assert_equal 2, p.lock_version # still increments version because of optimistic locking
assert_equal 1, p.versions(true).size
p.title = 'updated title'
assert p.save_version?
p.save
assert_equal 3, p.lock_version
assert_equal 1, p.versions(true).size # version 1 deleted
p.title = 'updated title!'
assert p.save_version?
p.save
assert_equal 4, p.lock_version
assert_equal 2, p.versions(true).size # version 1 deleted
end
def assert_page_title(p, i, version_field = :version)
p.title = "title#{i}"
p.save
assert_equal "title#{i}", p.title
assert_equal (i+4), p.send(version_field)
end
def test_find_versions
assert_equal 2, locked_pages(:welcome).versions.size
assert_equal 1, locked_pages(:welcome).versions.find(:all, :conditions => ['title LIKE ?', '%weblog%']).length
assert_equal 2, locked_pages(:welcome).versions.find(:all, :conditions => ['title LIKE ?', '%web%']).length
assert_equal 0, locked_pages(:thinking).versions.find(:all, :conditions => ['title LIKE ?', '%web%']).length
assert_equal 2, locked_pages(:welcome).versions.length
end
def test_find_version
assert_equal page_versions(:welcome_1), Page.find_version(pages(:welcome).id, 23)
assert_equal page_versions(:welcome_2), Page.find_version(pages(:welcome).id, 24)
assert_equal pages(:welcome), Page.find_version(pages(:welcome).id)
assert_equal page_versions(:welcome_1), pages(:welcome).find_version(23)
assert_equal page_versions(:welcome_2), pages(:welcome).find_version(24)
assert_equal pages(:welcome), pages(:welcome).find_version
assert_raise(ActiveRecord::RecordNotFound) { Page.find_version(pages(:welcome).id, 1) }
assert_raise(ActiveRecord::RecordNotFound) { Page.find_version(0, 23) }
end
def test_with_sequence
assert_equal 'widgets_seq', Widget.versioned_class.sequence_name
3.times { Widget.create! :name => 'new widget' }
assert_equal 3, Widget.count
assert_equal 3, Widget.versioned_class.count
end
def test_has_many_through
assert_equal [authors(:caged), authors(:mly)], pages(:welcome).authors
end
def test_has_many_through_with_custom_association
assert_equal [authors(:caged), authors(:mly)], pages(:welcome).revisors
end
def test_referential_integrity
pages(:welcome).destroy
assert_equal 0, Page.count
assert_equal 0, Page::Version.count
end
def test_association_options
association = Page.reflect_on_association(:versions)
options = association.options
assert_equal :delete_all, options[:dependent]
assert_equal 'version', options[:order]
association = Widget.reflect_on_association(:versions)
options = association.options
assert_equal :nullify, options[:dependent]
assert_equal 'version desc', options[:order]
assert_equal 'widget_id', options[:foreign_key]
widget = Widget.create! :name => 'new widget'
assert_equal 1, Widget.count
assert_equal 1, Widget.versioned_class.count
widget.destroy
assert_equal 0, Widget.count
assert_equal 1, Widget.versioned_class.count
end
def test_versioned_records_should_belong_to_parent
page = pages(:welcome)
page_version = page.versions.last
assert_equal page, page_version.page
end
def test_unaltered_attributes
landmarks(:washington).attributes = landmarks(:washington).attributes.except("id")
assert !landmarks(:washington).changed?
end
def test_unchanged_string_attributes
landmarks(:washington).attributes = landmarks(:washington).attributes.except("id").inject({}) { |params, (key, value)| params.update(key => value.to_s) }
assert !landmarks(:washington).changed?
end
def test_should_find_earliest_version
assert_equal page_versions(:welcome_1), pages(:welcome).versions.earliest
end
def test_should_find_latest_version
assert_equal page_versions(:welcome_2), pages(:welcome).versions.latest
end
def test_should_find_previous_version
assert_equal page_versions(:welcome_1), page_versions(:welcome_2).previous
assert_equal page_versions(:welcome_1), pages(:welcome).versions.before(page_versions(:welcome_2))
end
def test_should_find_next_version
assert_equal page_versions(:welcome_2), page_versions(:welcome_1).next
assert_equal page_versions(:welcome_2), pages(:welcome).versions.after(page_versions(:welcome_1))
end
def test_should_find_version_count
assert_equal 24, pages(:welcome).versions_count
assert_equal 24, page_versions(:welcome_1).versions_count
assert_equal 24, page_versions(:welcome_2).versions_count
end
end

View file

@ -0,0 +1,3 @@
# Include hook code here
require File.dirname(__FILE__) + '/lib/acts_as_watchable'
ActiveRecord::Base.send(:include, Redmine::Acts::Watchable)

View file

@ -0,0 +1,91 @@
# ActsAsWatchable
module Redmine
module Acts
module Watchable
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_watchable(options = {})
return if self.included_modules.include?(Redmine::Acts::Watchable::InstanceMethods)
class_eval do
has_many :watchers, :as => :watchable, :dependent => :delete_all
has_many :watcher_users, :through => :watchers, :source => :user, :validate => false
scope :watched_by, lambda { |user_id|
joins(:watchers).
where("#{Watcher.table_name}.user_id = ?", user_id)
}
attr_protected :watcher_ids, :watcher_user_ids
end
send :include, Redmine::Acts::Watchable::InstanceMethods
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
# Returns an array of users that are proposed as watchers
def addable_watcher_users
users = self.project.users.sort - self.watcher_users
if respond_to?(:visible?)
users.reject! {|user| !visible?(user)}
end
users
end
# Adds user as a watcher
def add_watcher(user)
# Rails does not reset the has_many :through association
watcher_users.reset
self.watchers << Watcher.new(:user => user)
end
# Removes user from the watchers list
def remove_watcher(user)
return nil unless user && user.is_a?(User)
# Rails does not reset the has_many :through association
watcher_users.reset
watchers.where(:user_id => user.id).delete_all
end
# Adds/removes watcher
def set_watcher(user, watching=true)
watching ? add_watcher(user) : remove_watcher(user)
end
# Overrides watcher_user_ids= to make user_ids uniq
def watcher_user_ids=(user_ids)
if user_ids.is_a?(Array)
user_ids = user_ids.uniq
end
super user_ids
end
# Returns true if object is watched by +user+
def watched_by?(user)
!!(user && self.watcher_user_ids.detect {|uid| uid == user.id })
end
def notified_watchers
notified = watcher_users.active.to_a
notified.reject! {|user| user.mail.blank? || user.mail_notification == 'none'}
if respond_to?(:visible?)
notified.reject! {|user| !visible?(user)}
end
notified
end
# Returns an array of watchers' email addresses
def watcher_recipients
notified_watchers.collect(&:mail)
end
module ClassMethods; end
end
end
end
end

1
lib/plugins/gravatar/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
coverage

View file

@ -0,0 +1,20 @@
Copyright (c) 2007 West Arete Computing, Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,55 @@
== Gravatar Plugin
This plugin provides a handful of view helpers for displaying gravatars
(globally-recognized avatars).
Gravatars allow users to configure an avatar to go with their email address at
a central location: http://gravatar.com. Gravatar-aware websites (such
as yours) can then look up and display each user's preferred avatar, without
having to handle avatar management. The user gets the benefit of not having to
set up an avatar for each site that they post on.
== Installation
cd ~/myapp
ruby script/plugin install git://github.com/woods/gravatar-plugin.git
or, if you're using piston[http://piston.rubyforge.org] (worth it!):
cd ~/myapp/vendor/plugins
piston import git://github.com/woods/gravatar-plugin.git
== Example
If you represent your users with a model that has an +email+ method (typical
for most rails authentication setups), then you can simply use this method
in your views:
<%= gravatar_for @user %>
This will be replaced with the full HTML +img+ tag necessary for displaying
that user's gravatar.
Other helpers are documented under GravatarHelper::PublicMethods.
== Acknowledgments
Thanks to Magnus Bergmark (http://github.com/Mange), who contributed the SSL
support in this plugin, as well as a few minor fixes.
The following people have also written gravatar-related Ruby libraries:
* Seth Rasmussen created the gravatar gem[http://gravatar.rubyforge.org]
* Matt McCray has also created a gravatar
plugin[http://mattmccray.com/svn/rails/plugins/gravatar_helper]
== Author
Scott A. Woods
West Arete Computing, Inc.
http://westarete.com
scott at westarete dot com
== TODO
* Add specs for ssl support
* Finish rdoc documentation

View file

@ -0,0 +1,32 @@
require 'spec/rake/spectask'
require 'rake/rdoctask'
desc 'Default: run all specs'
task :default => :spec
desc 'Run all application-specific specs'
Spec::Rake::SpecTask.new(:spec) do |t|
# t.rcov = true
end
desc "Report code statistics (KLOCs, etc) from the application"
task :stats do
RAILS_ROOT = File.dirname(__FILE__)
STATS_DIRECTORIES = [
%w(Libraries lib/),
%w(Specs spec/),
].collect { |name, dir| [ name, "#{RAILS_ROOT}/#{dir}" ] }.select { |name, dir| File.directory?(dir) }
require 'code_statistics'
CodeStatistics.new(*STATS_DIRECTORIES).to_s
end
namespace :doc do
desc 'Generate documentation for the assert_request plugin.'
Rake::RDocTask.new(:plugin) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'Gravatar Rails Plugin'
rdoc.options << '--line-numbers' << '--inline-source' << '--accessor' << 'cattr_accessor=rw'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end
end

View file

@ -0,0 +1,7 @@
author: Scott Woods, West Arete Computing
summary: View helpers for displaying gravatars.
homepage: http://github.com/woods/gravatar-plugin/
plugin: git://github.com/woods/gravatar-plugin.git
license: MIT
version: 0.1
rails_version: 1.0+

View file

@ -0,0 +1,2 @@
require 'gravatar'
ActionView::Base.send :include, GravatarHelper::PublicMethods

View file

@ -0,0 +1,86 @@
require 'digest/md5'
require 'cgi'
module GravatarHelper
# These are the options that control the default behavior of the public
# methods. They can be overridden during the actual call to the helper,
# or you can set them in your environment.rb as such:
#
# # Allow racier gravatars
# GravatarHelper::DEFAULT_OPTIONS[:rating] = 'R'
#
DEFAULT_OPTIONS = {
# The URL of a default image to display if the given email address does
# not have a gravatar.
:default => nil,
# The default size in pixels for the gravatar image (they're square).
:size => 50,
# The maximum allowed MPAA rating for gravatars. This allows you to
# exclude gravatars that may be out of character for your site.
:rating => 'PG',
# The alt text to use in the img tag for the gravatar. Since it's a
# decorational picture, the alt text should be empty according to the
# XHTML specs.
:alt => '',
# The title text to use for the img tag for the gravatar.
:title => '',
# The class to assign to the img tag for the gravatar.
:class => 'gravatar',
# Whether or not to display the gravatars using HTTPS instead of HTTP
:ssl => false,
}
# The methods that will be made available to your views.
module PublicMethods
# Return the HTML img tag for the given user's gravatar. Presumes that
# the given user object will respond_to "email", and return the user's
# email address.
def gravatar_for(user, options={})
gravatar(user.email, options)
end
# Return the HTML img tag for the given email address's gravatar.
def gravatar(email, options={})
src = h(gravatar_url(email, options))
options = DEFAULT_OPTIONS.merge(options)
[:class, :alt, :title].each { |opt| options[opt] = h(options[opt]) }
# double the size for hires displays
options[:srcset] = "#{gravatar_url(email, options.merge(size: options[:size].to_i * 2))} 2x"
image_tag src, options.except(:rating, :size, :default, :ssl)
end
# Returns the base Gravatar URL for the given email hash
def gravatar_api_url(hash)
"//www.gravatar.com/avatar/#{hash}"
end
# Return the gravatar URL for the given email address.
def gravatar_url(email, options={})
email_hash = Digest::MD5.hexdigest(email)
options = DEFAULT_OPTIONS.merge(options)
options[:default] = CGI::escape(options[:default]) unless options[:default].nil?
gravatar_api_url(email_hash).tap do |url|
opts = []
[:rating, :size, :default].each do |opt|
unless options[opt].nil?
value = h(options[opt])
opts << [opt, value].join('=')
end
end
url << "?#{opts.join('&')}" unless opts.empty?
end
end
end
end

View file

@ -0,0 +1,43 @@
require 'rubygems'
require 'erb' # to get "h"
require 'active_support' # to get "returning"
require File.dirname(__FILE__) + '/../lib/gravatar'
include GravatarHelper, GravatarHelper::PublicMethods, ERB::Util
describe "gravatar_url with a custom default URL" do
before(:each) do
@original_options = DEFAULT_OPTIONS.dup
DEFAULT_OPTIONS[:default] = "no_avatar.png"
@url = gravatar_url("somewhere")
end
it "should include the \"default\" argument in the result" do
@url.should match(/&default=no_avatar.png/)
end
after(:each) do
DEFAULT_OPTIONS.merge!(@original_options)
end
end
describe "gravatar_url with default settings" do
before(:each) do
@url = gravatar_url("somewhere")
end
it "should have a nil default URL" do
DEFAULT_OPTIONS[:default].should be_nil
end
it "should not include the \"default\" argument in the result" do
@url.should_not match(/&default=/)
end
end
describe "gravatar with a custom title option" do
it "should include the title in the result" do
gravatar('example@example.com', :title => "This is a title attribute").should match(/This is a title attribute/)
end
end

View file

@ -0,0 +1,37 @@
* Dump heavy lifting off to rack-openid gem. OpenIdAuthentication is just a simple controller concern.
* Fake HTTP method from OpenID server since they only support a GET. Eliminates the need to set an extra route to match the server's reply. [Josh Peek]
* OpenID 2.0 recommends that forms should use the field name "openid_identifier" rather than "openid_url" [Josh Peek]
* Return open_id_response.display_identifier to the application instead of .endpoints.claimed_id. [nbibler]
* Add Timeout protection [Rick]
* An invalid identity url passed through authenticate_with_open_id will no longer raise an InvalidOpenId exception. Instead it will return Result[:missing] to the completion block.
* Allow a return_to option to be used instead of the requested url [Josh Peek]
* Updated plugin to use Ruby OpenID 2.x.x [Josh Peek]
* Tied plugin to ruby-openid 1.1.4 gem until we can make it compatible with 2.x [DHH]
* Use URI instead of regexps to normalize the URL and gain free, better matching #8136 [dkubb]
* Allow -'s in #normalize_url [Rick]
* remove instance of mattr_accessor, it was breaking tests since they don't load ActiveSupport. Fix Timeout test [Rick]
* Throw a InvalidOpenId exception instead of just a RuntimeError when the URL can't be normalized [DHH]
* Just use the path for the return URL, so extra query parameters don't interfere [DHH]
* Added a new default database-backed store after experiencing trouble with the filestore on NFS. The file store is still available as an option [DHH]
* Added normalize_url and applied it to all operations going through the plugin [DHH]
* Removed open_id? as the idea of using the same input box for both OpenID and username has died -- use using_open_id? instead (which checks for the presence of params[:openid_url] by default) [DHH]
* Added OpenIdAuthentication::Result to make it easier to deal with default situations where you don't care to do something particular for each error state [DHH]
* Stop relying on root_url being defined, we can just grab the current url instead [DHH]

View file

@ -0,0 +1,223 @@
OpenIdAuthentication
====================
Provides a thin wrapper around the excellent ruby-openid gem from JanRan. Be sure to install that first:
gem install ruby-openid
To understand what OpenID is about and how it works, it helps to read the documentation for lib/openid/consumer.rb
from that gem.
The specification used is http://openid.net/specs/openid-authentication-2_0.html.
Prerequisites
=============
OpenID authentication uses the session, so be sure that you haven't turned that off.
Alternatively, you can use the file-based store, which just relies on on tmp/openids being present in RAILS_ROOT. But be aware that this store only works if you have a single application server. And it's not safe to use across NFS. It's recommended that you use the database store if at all possible. To use the file-based store, you'll also have to add this line to your config/environment.rb:
OpenIdAuthentication.store = :file
This particular plugin also relies on the fact that the authentication action allows for both POST and GET operations.
If you're using RESTful authentication, you'll need to explicitly allow for this in your routes.rb.
The plugin also expects to find a root_url method that points to the home page of your site. You can accomplish this by using a root route in config/routes.rb:
map.root :controller => 'articles'
This plugin relies on Rails Edge revision 6317 or newer.
Example
=======
This example is just to meant to demonstrate how you could use OpenID authentication. You might well want to add
salted hash logins instead of plain text passwords and other requirements on top of this. Treat it as a starting point,
not a destination.
Note that the User model referenced in the simple example below has an 'identity_url' attribute. You will want to add the same or similar field to whatever
model you are using for authentication.
Also of note is the following code block used in the example below:
authenticate_with_open_id do |result, identity_url|
...
end
In the above code block, 'identity_url' will need to match user.identity_url exactly. 'identity_url' will be a string in the form of 'http://example.com' -
If you are storing just 'example.com' with your user, the lookup will fail.
There is a handy method in this plugin called 'normalize_url' that will help with validating OpenID URLs.
OpenIdAuthentication.normalize_url(user.identity_url)
The above will return a standardized version of the OpenID URL - the above called with 'example.com' will return 'http://example.com/'
It will also raise an InvalidOpenId exception if the URL is determined to not be valid.
Use the above code in your User model and validate OpenID URLs before saving them.
config/routes.rb
map.root :controller => 'articles'
map.resource :session
app/views/sessions/new.erb
<% form_tag(session_url) do %>
<p>
<label for="name">Username:</label>
<%= text_field_tag "name" %>
</p>
<p>
<label for="password">Password:</label>
<%= password_field_tag %>
</p>
<p>
...or use:
</p>
<p>
<label for="openid_identifier">OpenID:</label>
<%= text_field_tag "openid_identifier" %>
</p>
<p>
<%= submit_tag 'Sign in', :disable_with => "Signing in&hellip;" %>
</p>
<% end %>
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
if using_open_id?
open_id_authentication
else
password_authentication(params[:name], params[:password])
end
end
protected
def password_authentication(name, password)
if @current_user = @account.users.authenticate(params[:name], params[:password])
successful_login
else
failed_login "Sorry, that username/password doesn't work"
end
end
def open_id_authentication
authenticate_with_open_id do |result, identity_url|
if result.successful?
if @current_user = @account.users.find_by_identity_url(identity_url)
successful_login
else
failed_login "Sorry, no user by that identity URL exists (#{identity_url})"
end
else
failed_login result.message
end
end
end
private
def successful_login
session[:user_id] = @current_user.id
redirect_to(root_url)
end
def failed_login(message)
flash[:error] = message
redirect_to(new_session_url)
end
end
If you're fine with the result messages above and don't need individual logic on a per-failure basis,
you can collapse the case into a mere boolean:
def open_id_authentication
authenticate_with_open_id do |result, identity_url|
if result.successful? && @current_user = @account.users.find_by_identity_url(identity_url)
successful_login
else
failed_login(result.message || "Sorry, no user by that identity URL exists (#{identity_url})")
end
end
end
Simple Registration OpenID Extension
====================================
Some OpenID Providers support this lightweight profile exchange protocol. See more: http://www.openidenabled.com/openid/simple-registration-extension
You can support it in your app by changing #open_id_authentication
def open_id_authentication(identity_url)
# Pass optional :required and :optional keys to specify what sreg fields you want.
# Be sure to yield registration, a third argument in the #authenticate_with_open_id block.
authenticate_with_open_id(identity_url,
:required => [ :nickname, :email ],
:optional => :fullname) do |result, identity_url, registration|
case result.status
when :missing
failed_login "Sorry, the OpenID server couldn't be found"
when :invalid
failed_login "Sorry, but this does not appear to be a valid OpenID"
when :canceled
failed_login "OpenID verification was canceled"
when :failed
failed_login "Sorry, the OpenID verification failed"
when :successful
if @current_user = @account.users.find_by_identity_url(identity_url)
assign_registration_attributes!(registration)
if current_user.save
successful_login
else
failed_login "Your OpenID profile registration failed: " +
@current_user.errors.full_messages.to_sentence
end
else
failed_login "Sorry, no user by that identity URL exists"
end
end
end
end
# registration is a hash containing the valid sreg keys given above
# use this to map them to fields of your user model
def assign_registration_attributes!(registration)
model_to_registration_mapping.each do |model_attribute, registration_attribute|
unless registration[registration_attribute].blank?
@current_user.send("#{model_attribute}=", registration[registration_attribute])
end
end
end
def model_to_registration_mapping
{ :login => 'nickname', :email => 'email', :display_name => 'fullname' }
end
Attribute Exchange OpenID Extension
===================================
Some OpenID providers also support the OpenID AX (attribute exchange) protocol for exchanging identity information between endpoints. See more: http://openid.net/specs/openid-attribute-exchange-1_0.html
Accessing AX data is very similar to the Simple Registration process, described above -- just add the URI identifier for the AX field to your :optional or :required parameters. For example:
authenticate_with_open_id(identity_url,
:required => [ :email, 'http://schema.openid.net/birthDate' ]) do |result, identity_url, registration|
This would provide the sreg data for :email, and the AX data for 'http://schema.openid.net/birthDate'
Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license

View file

@ -0,0 +1,22 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test the open_id_authentication plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for the open_id_authentication plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'OpenIdAuthentication'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end

View file

@ -0,0 +1,11 @@
class OpenIdAuthenticationTablesGenerator < Rails::Generator::NamedBase
def initialize(runtime_args, runtime_options = {})
super
end
def manifest
record do |m|
m.migration_template 'migration.rb', 'db/migrate'
end
end
end

View file

@ -0,0 +1,20 @@
class <%= class_name %> < ActiveRecord::Migration
def self.up
create_table :open_id_authentication_associations, :force => true do |t|
t.integer :issued, :lifetime
t.string :handle, :assoc_type
t.binary :server_url, :secret
end
create_table :open_id_authentication_nonces, :force => true do |t|
t.integer :timestamp, :null => false
t.string :server_url, :null => true
t.string :salt, :null => false
end
end
def self.down
drop_table :open_id_authentication_associations
drop_table :open_id_authentication_nonces
end
end

View file

@ -0,0 +1,26 @@
class <%= class_name %> < ActiveRecord::Migration
def self.up
drop_table :open_id_authentication_settings
drop_table :open_id_authentication_nonces
create_table :open_id_authentication_nonces, :force => true do |t|
t.integer :timestamp, :null => false
t.string :server_url, :null => true
t.string :salt, :null => false
end
end
def self.down
drop_table :open_id_authentication_nonces
create_table :open_id_authentication_nonces, :force => true do |t|
t.integer :created
t.string :nonce
end
create_table :open_id_authentication_settings, :force => true do |t|
t.string :setting
t.binary :value
end
end
end

View file

@ -0,0 +1,11 @@
class UpgradeOpenIdAuthenticationTablesGenerator < Rails::Generator::NamedBase
def initialize(runtime_args, runtime_options = {})
super
end
def manifest
record do |m|
m.migration_template 'migration.rb', 'db/migrate'
end
end
end

View file

@ -0,0 +1,12 @@
if Rails.version < '3'
config.gem 'rack-openid', :lib => 'rack/openid', :version => '>=0.2.1'
end
require 'open_id_authentication'
config.middleware.use OpenIdAuthentication
config.after_initialize do
OpenID::Util.logger = Rails.logger
ActionController::Base.send :include, OpenIdAuthentication
end

View file

@ -0,0 +1,159 @@
require 'uri'
require 'openid'
require 'rack/openid'
module OpenIdAuthentication
def self.new(app)
store = OpenIdAuthentication.store
if store.nil?
Rails.logger.warn "OpenIdAuthentication.store is nil. Using in-memory store."
end
::Rack::OpenID.new(app, OpenIdAuthentication.store)
end
def self.store
@@store
end
def self.store=(*store_option)
store, *parameters = *([ store_option ].flatten)
@@store = case store
when :memory
require 'openid/store/memory'
OpenID::Store::Memory.new
when :file
require 'openid/store/filesystem'
OpenID::Store::Filesystem.new(Rails.root.join('tmp/openids'))
when :memcache
require 'memcache'
require 'openid/store/memcache'
OpenID::Store::Memcache.new(MemCache.new(parameters))
else
store
end
end
self.store = nil
class InvalidOpenId < StandardError
end
class Result
ERROR_MESSAGES = {
:missing => "Sorry, the OpenID server couldn't be found",
:invalid => "Sorry, but this does not appear to be a valid OpenID",
:canceled => "OpenID verification was canceled",
:failed => "OpenID verification failed",
:setup_needed => "OpenID verification needs setup"
}
def self.[](code)
new(code)
end
def initialize(code)
@code = code
end
def status
@code
end
ERROR_MESSAGES.keys.each { |state| define_method("#{state}?") { @code == state } }
def successful?
@code == :successful
end
def unsuccessful?
ERROR_MESSAGES.keys.include?(@code)
end
def message
ERROR_MESSAGES[@code]
end
end
# normalizes an OpenID according to http://openid.net/specs/openid-authentication-2_0.html#normalization
def self.normalize_identifier(identifier)
# clean up whitespace
identifier = identifier.to_s.strip
# if an XRI has a prefix, strip it.
identifier.gsub!(/xri:\/\//i, '')
# dodge XRIs -- TODO: validate, don't just skip.
unless ['=', '@', '+', '$', '!', '('].include?(identifier.at(0))
# does it begin with http? if not, add it.
identifier = "http://#{identifier}" unless identifier =~ /^http/i
# strip any fragments
identifier.gsub!(/\#(.*)$/, '')
begin
uri = URI.parse(identifier)
uri.scheme = uri.scheme.downcase if uri.scheme # URI should do this
identifier = uri.normalize.to_s
rescue URI::InvalidURIError
raise InvalidOpenId.new("#{identifier} is not an OpenID identifier")
end
end
return identifier
end
protected
# The parameter name of "openid_identifier" is used rather than
# the Rails convention "open_id_identifier" because that's what
# the specification dictates in order to get browser auto-complete
# working across sites
def using_open_id?(identifier = nil) #:doc:
identifier ||= open_id_identifier
!identifier.blank? || request.env[Rack::OpenID::RESPONSE]
end
def authenticate_with_open_id(identifier = nil, options = {}, &block) #:doc:
identifier ||= open_id_identifier
if request.env[Rack::OpenID::RESPONSE]
complete_open_id_authentication(&block)
else
begin_open_id_authentication(identifier, options, &block)
end
end
private
def open_id_identifier
params[:openid_identifier] || params[:openid_url]
end
def begin_open_id_authentication(identifier, options = {})
options[:identifier] = identifier
value = Rack::OpenID.build_header(options)
response.headers[Rack::OpenID::AUTHENTICATE_HEADER] = value
head :unauthorized
end
def complete_open_id_authentication
response = request.env[Rack::OpenID::RESPONSE]
identifier = response.display_identifier
case response.status
when OpenID::Consumer::SUCCESS
yield Result[:successful], identifier,
OpenID::SReg::Response.from_success_response(response)
when :missing
yield Result[:missing], identifier, nil
when :invalid
yield Result[:invalid], identifier, nil
when OpenID::Consumer::CANCEL
yield Result[:canceled], identifier, nil
when OpenID::Consumer::FAILURE
yield Result[:failed], identifier, nil
when OpenID::Consumer::SETUP_NEEDED
yield Result[:setup_needed], response.setup_url, nil
end
end
end

View file

@ -0,0 +1,9 @@
module OpenIdAuthentication
class Association < ActiveRecord::Base
self.table_name = :open_id_authentication_associations
def from_record
OpenID::Association.new(handle, secret, issued, lifetime, assoc_type)
end
end
end

View file

@ -0,0 +1,55 @@
require 'openid/store/interface'
module OpenIdAuthentication
class DbStore < OpenID::Store::Interface
def self.cleanup_nonces
now = Time.now.to_i
Nonce.delete_all(["timestamp > ? OR timestamp < ?", now + OpenID::Nonce.skew, now - OpenID::Nonce.skew])
end
def self.cleanup_associations
now = Time.now.to_i
Association.delete_all(['issued + lifetime > ?',now])
end
def store_association(server_url, assoc)
remove_association(server_url, assoc.handle)
Association.create(:server_url => server_url,
:handle => assoc.handle,
:secret => assoc.secret,
:issued => assoc.issued,
:lifetime => assoc.lifetime,
:assoc_type => assoc.assoc_type)
end
def get_association(server_url, handle = nil)
assocs = if handle.blank?
Association.find_all_by_server_url(server_url)
else
Association.find_all_by_server_url_and_handle(server_url, handle)
end
assocs.reverse.each do |assoc|
a = assoc.from_record
if a.expires_in == 0
assoc.destroy
else
return a
end
end if assocs.any?
return nil
end
def remove_association(server_url, handle)
Association.delete_all(['server_url = ? AND handle = ?', server_url, handle]) > 0
end
def use_nonce(server_url, timestamp, salt)
return false if Nonce.find_by_server_url_and_timestamp_and_salt(server_url, timestamp, salt)
return false if (timestamp - Time.now.to_i).abs > OpenID::Nonce.skew
Nonce.create(:server_url => server_url, :timestamp => timestamp, :salt => salt)
return true
end
end
end

View file

@ -0,0 +1,73 @@
require 'digest/sha1'
require 'openid/store/interface'
module OpenIdAuthentication
class MemCacheStore < OpenID::Store::Interface
def initialize(*addresses)
@connection = ActiveSupport::Cache::MemCacheStore.new(addresses)
end
def store_association(server_url, assoc)
server_key = association_server_key(server_url)
assoc_key = association_key(server_url, assoc.handle)
assocs = @connection.read(server_key) || {}
assocs[assoc.issued] = assoc_key
@connection.write(server_key, assocs)
@connection.write(assoc_key, assoc, :expires_in => assoc.lifetime)
end
def get_association(server_url, handle = nil)
if handle
@connection.read(association_key(server_url, handle))
else
server_key = association_server_key(server_url)
assocs = @connection.read(server_key)
return if assocs.nil?
last_key = assocs[assocs.keys.sort.last]
@connection.read(last_key)
end
end
def remove_association(server_url, handle)
server_key = association_server_key(server_url)
assoc_key = association_key(server_url, handle)
assocs = @connection.read(server_key)
return false unless assocs && assocs.has_value?(assoc_key)
assocs = assocs.delete_if { |key, value| value == assoc_key }
@connection.write(server_key, assocs)
@connection.delete(assoc_key)
return true
end
def use_nonce(server_url, timestamp, salt)
return false if @connection.read(nonce_key(server_url, salt))
return false if (timestamp - Time.now.to_i).abs > OpenID::Nonce.skew
@connection.write(nonce_key(server_url, salt), timestamp, :expires_in => OpenID::Nonce.skew)
return true
end
private
def association_key(server_url, handle = nil)
"openid_association_#{digest(server_url)}_#{digest(handle)}"
end
def association_server_key(server_url)
"openid_association_server_#{digest(server_url)}"
end
def nonce_key(server_url, salt)
"openid_nonce_#{digest(server_url)}_#{digest(salt)}"
end
def digest(text)
Digest::SHA1.hexdigest(text)
end
end
end

View file

@ -0,0 +1,5 @@
module OpenIdAuthentication
class Nonce < ActiveRecord::Base
self.table_name = :open_id_authentication_nonces
end
end

Some files were not shown because too many files have changed in this diff Show more