lean-ruport 0.3.8
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of lean-ruport might be problematic. Click here for more details.
- data/ACKNOWLEDGEMENTS +33 -0
- data/AUTHORS +19 -0
- data/CHANGELOG +206 -0
- data/COPYING +340 -0
- data/LICENSE +7 -0
- data/README +209 -0
- data/Rakefile +54 -0
- data/TODO +27 -0
- data/lib/ruport.rb +58 -0
- data/lib/ruport/config.rb +114 -0
- data/lib/ruport/data_row.rb +144 -0
- data/lib/ruport/data_set.rb +221 -0
- data/lib/ruport/format.rb +116 -0
- data/lib/ruport/format/builder.rb +89 -0
- data/lib/ruport/format/document.rb +77 -0
- data/lib/ruport/format/open_node.rb +36 -0
- data/lib/ruport/parser.rb +202 -0
- data/lib/ruport/query.rb +208 -0
- data/lib/ruport/query/sql_split.rb +33 -0
- data/lib/ruport/report.rb +116 -0
- data/lib/ruport/report/mailer.rb +48 -0
- data/test/samples/addressbook.csv +6 -0
- data/test/samples/car_ads.txt +505 -0
- data/test/samples/data.csv +3 -0
- data/test/samples/document.xml +22 -0
- data/test/samples/five_lines.txt +5 -0
- data/test/samples/five_paragraphs.txt +9 -0
- data/test/samples/ross_report.txt +58530 -0
- data/test/samples/ruport_test.sql +8 -0
- data/test/samples/stonecodeblog.sql +279 -0
- data/test/samples/test.sql +2 -0
- data/test/samples/test.yaml +3 -0
- data/test/tc_builder.rb +116 -0
- data/test/tc_config.rb +41 -0
- data/test/tc_data_row.rb +36 -0
- data/test/tc_data_set.rb +141 -0
- data/test/tc_database.rb +25 -0
- data/test/tc_document.rb +42 -0
- data/test/tc_element.rb +18 -0
- data/test/tc_page.rb +42 -0
- data/test/tc_query.rb +55 -0
- data/test/tc_reading.rb +60 -0
- data/test/tc_report.rb +31 -0
- data/test/tc_section.rb +45 -0
- data/test/tc_sql_split.rb +18 -0
- data/test/tc_state.rb +142 -0
- data/test/ts_all.rb +9 -0
- data/test/ts_format.rb +5 -0
- data/test/ts_parser.rb +10 -0
- metadata +102 -0
@@ -0,0 +1,144 @@
|
|
1
|
+
# --
|
2
|
+
# data_row.rb : Ruby Reports row abstraction
|
3
|
+
#
|
4
|
+
# Author: Gregory T. Brown (gregory.t.brown at gmail dot com)
|
5
|
+
#
|
6
|
+
# Copyright (c) 2006, All Rights Reserved.
|
7
|
+
#
|
8
|
+
# This is free software. You may modify and redistribute this freely under
|
9
|
+
# your choice of the GNU General Public License or the Ruby License.
|
10
|
+
#
|
11
|
+
# See LICENSE and COPYING for details
|
12
|
+
# ++
|
13
|
+
module Ruport
|
14
|
+
|
15
|
+
# DataRows are Enumerable lists which can be accessed by field name or ordinal
|
16
|
+
# position.
|
17
|
+
#
|
18
|
+
# They feature a tagging system, allowing them to be easily
|
19
|
+
# compared or recalled.
|
20
|
+
#
|
21
|
+
# DataRows form the elements of DataSets
|
22
|
+
#
|
23
|
+
class DataRow
|
24
|
+
|
25
|
+
include Enumerable
|
26
|
+
|
27
|
+
# Takes data and field names as well as some optional parameters and
|
28
|
+
# constructs a DataRow.
|
29
|
+
#
|
30
|
+
#
|
31
|
+
# <tt>data</tt> can be specified in Hash, Array, or DataRow form
|
32
|
+
#
|
33
|
+
# Options:
|
34
|
+
# <tt>:filler</tt>:: this will be used as a default value for empty
|
35
|
+
# <tt>:tags</tt>:: an initial set of tags for the row
|
36
|
+
#
|
37
|
+
#
|
38
|
+
# Examples:
|
39
|
+
# >> Ruport::DataRow.new [1,2,3,4,5], [:a,:b,:c,:d,:e],
|
40
|
+
# :tags => %w[cat dog]
|
41
|
+
# => #<Ruport::DataRow:0xb77e4b04 @fields=[:a, :b, :c, :d, :e],
|
42
|
+
# @data=[1, 2, 3, 4, 5], @tags=["cat", "dog"]>
|
43
|
+
#
|
44
|
+
# >> Ruport::DataRow.new({ :a => 'moo', :c => 'caw'} , [:a,:b,:c,:d,:e],
|
45
|
+
# :tags => %w[cat dog])
|
46
|
+
# => #<Ruport::DataRow:0xb77c298c @fields=[:a, :b, :c, :d, :e],
|
47
|
+
# @data=["moo", nil, "caw", nil, nil], @tags=["cat", "dog"]>
|
48
|
+
#
|
49
|
+
# >> Ruport::DataRow.new [1,2,3], [:a,:b,:c,:d,:e], :tags => %w[cat dog],
|
50
|
+
# :filler => 0
|
51
|
+
# => #<Ruport::DataRow:0xb77bb4d4 @fields=[:a, :b, :c, :d, :e],
|
52
|
+
# @data=[1, 2, 3, 0, 0], @tags=["cat", "dog"]>
|
53
|
+
#
|
54
|
+
def initialize( data, fields, options={} )
|
55
|
+
@fields = fields
|
56
|
+
@tags = options[:tags] || {}
|
57
|
+
@data = []
|
58
|
+
nr_action =
|
59
|
+
if data.kind_of?(Array)
|
60
|
+
lambda { |key, index| @data[index] = data.shift || options[:filler] }
|
61
|
+
elsif data.kind_of?(DataRow)
|
62
|
+
lambda { |key, index| @data = data.to_a }
|
63
|
+
else
|
64
|
+
lambda { |key, index| @data[index] = data[key] || options[:filler] }
|
65
|
+
end
|
66
|
+
@fields.each_with_index { |key, index| nr_action.call(key,index) }
|
67
|
+
end
|
68
|
+
|
69
|
+
attr_accessor :fields, :tags
|
70
|
+
|
71
|
+
# Returns an array of values. Should probably return a DataRow.
|
72
|
+
# Loses field information.
|
73
|
+
def +(other)
|
74
|
+
self.to_a + other.to_a
|
75
|
+
end
|
76
|
+
|
77
|
+
# Lets you access individual fields
|
78
|
+
#
|
79
|
+
# i.e. row["phone"] or row[4]
|
80
|
+
def [](key)
|
81
|
+
key.kind_of?(Fixnum) ? @data[key] : @data[@fields.index(key)]
|
82
|
+
end
|
83
|
+
|
84
|
+
# Lets you set field values
|
85
|
+
#
|
86
|
+
# i.e. row["phone"] = '2038291203', row[7] = "allen"
|
87
|
+
def []=(key,value)
|
88
|
+
if key.kind_of?(Fixnum)
|
89
|
+
@data[key] = value
|
90
|
+
else
|
91
|
+
@data[@fields.index(key)] = value
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Converts the DataRow to a plain old Array
|
96
|
+
def to_a
|
97
|
+
@data
|
98
|
+
end
|
99
|
+
|
100
|
+
# Converts the DataRow to a string representation
|
101
|
+
# for outputting to screen.
|
102
|
+
def to_s
|
103
|
+
"[" + @data.join(",") + "]"
|
104
|
+
end
|
105
|
+
|
106
|
+
# Checks to see row includes the tag given.
|
107
|
+
#
|
108
|
+
# Example:
|
109
|
+
#
|
110
|
+
# >> row.has_tag? :running_balance
|
111
|
+
# => true
|
112
|
+
#
|
113
|
+
def has_tag?(tag)
|
114
|
+
@tags.include?(tag)
|
115
|
+
end
|
116
|
+
|
117
|
+
# Iterates through DataRow elements. Accepts a block.
|
118
|
+
def each(&action)
|
119
|
+
@data.each(&action)
|
120
|
+
end
|
121
|
+
|
122
|
+
# Allows you to add a tag to a row.
|
123
|
+
#
|
124
|
+
# Examples:
|
125
|
+
#
|
126
|
+
# row.tag_as(:jay_cross) if row["product"].eql?("im_courier")
|
127
|
+
# row.tag_as(:running_balance) if row.fields.include?("RB")
|
128
|
+
#
|
129
|
+
def tag_as(something)
|
130
|
+
@tags[something] = true
|
131
|
+
end
|
132
|
+
|
133
|
+
# Compares two DataRow objects. If values and fields are the same
|
134
|
+
# (and in the correct order) returns true. Otherwise returns false.
|
135
|
+
def ==(other)
|
136
|
+
self.to_a.eql?(other.to_a) && @fields.eql?(other.fields)
|
137
|
+
end
|
138
|
+
|
139
|
+
# Synonym for DataRow#==
|
140
|
+
def eql?
|
141
|
+
self == other
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
# data_set.rb : Ruby Reports core datastructure.
|
2
|
+
#
|
3
|
+
# Author: Gregory T. Brown (gregory.t.brown at gmail dot com)
|
4
|
+
#
|
5
|
+
# Copyright (c) 2006, All Rights Reserved.
|
6
|
+
#
|
7
|
+
# This is free software. You may modify and redistribute this freely under
|
8
|
+
# your choice of the GNU General Public License or the Ruby License.
|
9
|
+
#
|
10
|
+
# See LICENSE and COPYING for details
|
11
|
+
module Ruport
|
12
|
+
|
13
|
+
# The DataSet is the core datastructure for Ruport. It provides methods that
|
14
|
+
# allow you to compare and combine query results, data loaded in from CSVs,
|
15
|
+
# and user-defined sets of data.
|
16
|
+
#
|
17
|
+
# It is tightly integrated with Ruport's formatting and query systems, so if
|
18
|
+
# you'd like to take advantage of these models, you will probably find DataSet
|
19
|
+
# useful.
|
20
|
+
#
|
21
|
+
# Sample Usage:
|
22
|
+
#
|
23
|
+
# my_data = [[1,2,3],[4,5,6],[7,8,9]]
|
24
|
+
#
|
25
|
+
# ds = Ruport::DataSet.new([:col1, :col2, :col3],my_data)
|
26
|
+
# ds << [ 10, 11, 12]
|
27
|
+
# ds << { :col3 => 15, :col1 => 13, :col2 => 14 }
|
28
|
+
# puts ds.select_columns(:col1, :col3).to_csv
|
29
|
+
#
|
30
|
+
# Output:
|
31
|
+
#
|
32
|
+
# col1,col3
|
33
|
+
# 1,3
|
34
|
+
# 4,6
|
35
|
+
# 7,9
|
36
|
+
# 10,12
|
37
|
+
# 13,15
|
38
|
+
#
|
39
|
+
# The wild and crazy might want to try the Array hack:
|
40
|
+
#
|
41
|
+
# puts [[1,2,3],[4,5,6],[7,8,9]].to_ds(%w[col1 col2 col3])
|
42
|
+
#
|
43
|
+
# Output:
|
44
|
+
#
|
45
|
+
# fields: ( col1, col2, col3 )
|
46
|
+
# row0: ( 1, 2, 3 )
|
47
|
+
# row1: ( 4, 5, 6 )
|
48
|
+
# row2: ( 7, 8, 9 )
|
49
|
+
#
|
50
|
+
class DataSet
|
51
|
+
|
52
|
+
include Enumerable
|
53
|
+
|
54
|
+
# DataSets must be given a set of fields to be defined.
|
55
|
+
#
|
56
|
+
# These field names will define the columns for the DataSet and how you
|
57
|
+
# access them.
|
58
|
+
#
|
59
|
+
# data = Ruport::DataSet.new %w[ id name phone ]
|
60
|
+
#
|
61
|
+
# You can optionally pass in some content as well. (Must be Enumerable)
|
62
|
+
#
|
63
|
+
# content = [ %w[ a1 gregory 203-525-0523 ],
|
64
|
+
# %w[ a2 james 555-555-5555 ] ]
|
65
|
+
#
|
66
|
+
# data = Ruport::DataSet.new(%w[ id name phone],content)
|
67
|
+
def initialize(fields=[], content=nil, default=nil)
|
68
|
+
@fields = fields
|
69
|
+
@data = []
|
70
|
+
@default = default
|
71
|
+
content.each { |r| self << r } if content
|
72
|
+
end
|
73
|
+
|
74
|
+
#an array which contains column names
|
75
|
+
attr_accessor :fields
|
76
|
+
|
77
|
+
#the default value to fill empty cells with
|
78
|
+
attr_accessor :default
|
79
|
+
|
80
|
+
#data holds the elements of the Row
|
81
|
+
attr_reader :data
|
82
|
+
|
83
|
+
#provides a deep copy of the DataSet.
|
84
|
+
def clone
|
85
|
+
DataSet.new(@fields,@data)
|
86
|
+
end
|
87
|
+
|
88
|
+
#Allows ordinal access to rows
|
89
|
+
#
|
90
|
+
# my_data[2] -> Ruport::DataRow
|
91
|
+
def [](index)
|
92
|
+
@data[index]
|
93
|
+
end
|
94
|
+
|
95
|
+
#allows setting of rows (providing a DataRow is passed in)
|
96
|
+
def []=(index,value)
|
97
|
+
throw "Invalid object type" unless value.kind_of?(DataRow)
|
98
|
+
@data[index] = value
|
99
|
+
end
|
100
|
+
|
101
|
+
# appends a row to the DataSet
|
102
|
+
# can be added as an array or a keyed hash-like object.
|
103
|
+
#
|
104
|
+
# Columns left undefined will be filled with DataSet#default values.
|
105
|
+
#
|
106
|
+
# data << [ 1, 2, 3 ]
|
107
|
+
# data << { :some_field_name => 3, :other => 2, :another => 1 }
|
108
|
+
def << ( stuff, filler=@default )
|
109
|
+
@data << DataRow.new(stuff,@fields,:filler => filler)
|
110
|
+
end
|
111
|
+
|
112
|
+
# checks if one dataset equals another
|
113
|
+
def eql?(data2)
|
114
|
+
return false unless ( @data.length == data2.data.length and
|
115
|
+
@fields.eql?(data2.fields) )
|
116
|
+
@data.each_with_index do |row, r_index|
|
117
|
+
row.each_with_index do |field, f_index|
|
118
|
+
return false unless field.eql?(data2[r_index][f_index])
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
return true
|
123
|
+
end
|
124
|
+
|
125
|
+
# checks if one dataset equals another
|
126
|
+
def ==(data2)
|
127
|
+
eql?(data2)
|
128
|
+
end
|
129
|
+
|
130
|
+
# Returns true if DataSet contains no rows, false otherwise.
|
131
|
+
def empty?
|
132
|
+
return @data.empty?
|
133
|
+
end
|
134
|
+
|
135
|
+
# Allows loading of CSV files or YAML dumps. Returns a DataSet
|
136
|
+
#
|
137
|
+
# FasterCSV will be used if it is installed.
|
138
|
+
#
|
139
|
+
# my_data = Ruport::DataSet.load("foo.csv")
|
140
|
+
# my_data = Ruport::DataSet.load("foo.yaml")
|
141
|
+
# my_data = Ruport::DataSet.load("foo.yml")
|
142
|
+
def self.load ( source, default="")
|
143
|
+
case source
|
144
|
+
when /\.(yaml|yml)/
|
145
|
+
return YAML.load(File.open(source))
|
146
|
+
when /\.csv/
|
147
|
+
csv_klass = defined?(FasterCSV) ? FasterCSV : CSV
|
148
|
+
input = csv_klass.read(source) if source =~ /\.csv/
|
149
|
+
loaded_data = self.new
|
150
|
+
loaded_data.fields = input[0]
|
151
|
+
loaded_data.default = default
|
152
|
+
input[1..-1].each { |row| loaded_data << row }
|
153
|
+
return loaded_data
|
154
|
+
else
|
155
|
+
raise "Invalid file type"
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# Iterates through the rows, yielding a DataRow for each.
|
160
|
+
def each(&action)
|
161
|
+
@data.each(&action)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Returns a new DataSet composed of the fields specified.
|
165
|
+
def select_columns(*fields)
|
166
|
+
rows = fields.inject([]) { |s,e| s << map { |row| row[e] } }.transpose
|
167
|
+
my_data = DataSet.new(fields,rows)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Returns a new DataSet with the specified fields removed
|
171
|
+
def remove_columns(*fields)
|
172
|
+
select_fields(*(@fields-fields))
|
173
|
+
end
|
174
|
+
|
175
|
+
# removes the specified fields from this DataSet (DESTRUCTIVE!)
|
176
|
+
def remove_columns!(*fields)
|
177
|
+
@fields -= fields
|
178
|
+
@data = select_fields(*(@fields)).to_a
|
179
|
+
end
|
180
|
+
|
181
|
+
# uses Format::Builder to render DataSets in various ready to output
|
182
|
+
# formats.
|
183
|
+
#
|
184
|
+
# data.as(:html) -> String
|
185
|
+
#
|
186
|
+
# data.as(:text) do |builder|
|
187
|
+
# builder.range = 2..4 -> String
|
188
|
+
# builder.header = "My Title"
|
189
|
+
# end
|
190
|
+
#
|
191
|
+
# To add new formats to this function, simply re-open Format::Builder
|
192
|
+
# and add methods like <tt>render_my_format_name</tt>.
|
193
|
+
#
|
194
|
+
# This will enable <tt>data.as(:my_format_name)</tt>
|
195
|
+
def as(format,&action)
|
196
|
+
builder = Format::Builder.new( self )
|
197
|
+
builder.format = format
|
198
|
+
action.call(builder) if block_given?
|
199
|
+
builder.render
|
200
|
+
end
|
201
|
+
|
202
|
+
# Converts a DataSet to CSV
|
203
|
+
def to_csv; as(:csv) end
|
204
|
+
|
205
|
+
# Converts a Dataset to html
|
206
|
+
def to_html; as(:html) end
|
207
|
+
|
208
|
+
# Readable string representation of the DataSet
|
209
|
+
def to_s; as(:text) end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
class Array
|
214
|
+
|
215
|
+
# Will convert Arrays of Enumerable objects to DataSets.
|
216
|
+
# May have dragons.
|
217
|
+
def to_ds(fields,default=nil)
|
218
|
+
Ruport::DataSet.new(fields,to_a,default)
|
219
|
+
end
|
220
|
+
|
221
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# format.rb : Ruby Reports formatting module
|
2
|
+
#
|
3
|
+
# Author: Gregory T. Brown (gregory.t.brown at gmail dot com)
|
4
|
+
#
|
5
|
+
# Copyright (c) 2006, All Rights Reserved.
|
6
|
+
#
|
7
|
+
# This is free software. You may modify and redistribute this freely under
|
8
|
+
# your choice of the GNU General Public License or the Ruby License.
|
9
|
+
#
|
10
|
+
# See LICENSE and COPYING for details
|
11
|
+
%w[builder open_node document].each { |lib| require "ruport/format/#{lib}" }
|
12
|
+
begin; require "faster_csv"; rescue LoadError; require "csv"; end
|
13
|
+
begin; require "pdf/writer"; rescue LoadError; nil; end
|
14
|
+
module Ruport
|
15
|
+
|
16
|
+
|
17
|
+
# Ruport's Format model is meant to help get your data in a suitable format for
|
18
|
+
# output. Rather than make too many assumptions about how you will want your
|
19
|
+
# data to look, a number of tools have been built so that you can quickly define
|
20
|
+
# those things yourself.
|
21
|
+
#
|
22
|
+
# There are three main sets of functionality the Ruport::Format model provides.
|
23
|
+
# * Structured printable document support ( Format::Document and friends)
|
24
|
+
# * Text filter support ( Report#render and the Format class)
|
25
|
+
# * Support for DataSet Formatting ( Format::Builder)
|
26
|
+
#
|
27
|
+
# The support for structured printable documents is currently geared towards PDF
|
28
|
+
# support and needs some additional work to be truly useful. Suggestions would
|
29
|
+
# be much appreciated.
|
30
|
+
#
|
31
|
+
# Format::Builder lets you define functions that will be used via DataSet#as
|
32
|
+
# This is primary geared towards tabular data output, but there is no reason why
|
33
|
+
# DataSet#as and the <tt>render_foo</tt> methods of Format::Builder cannot be
|
34
|
+
# adapted to fit whatever needs you may need.
|
35
|
+
#
|
36
|
+
# The filters implemented in the Format class are meant to process strings or
|
37
|
+
# entire templates. The Format class will soon automatically build a
|
38
|
+
# Ruport::Parser for any string input. By default, filters are provided to
|
39
|
+
# process erb, pure ruby, and redcloth. It is trivial to extend this
|
40
|
+
# functionality though.
|
41
|
+
#
|
42
|
+
# This is best shown by a simple example:
|
43
|
+
#
|
44
|
+
# a = Ruport::Report.new
|
45
|
+
# Ruport::Format.register_filter :reverser do
|
46
|
+
# content.reverse
|
47
|
+
# end
|
48
|
+
# a.render "somestring", :filters => [:reverser]
|
49
|
+
#
|
50
|
+
# Output: "gnirtsemos"
|
51
|
+
#
|
52
|
+
# Filters can be combined, and you can run them in different orders to obtain
|
53
|
+
# different results.
|
54
|
+
#
|
55
|
+
# See the source for the built in filters for ideas.
|
56
|
+
#
|
57
|
+
# Also, see Report#render for how to bind Format objects to your own classes.
|
58
|
+
#
|
59
|
+
# When combined, filters, data set output templates, and structured printable
|
60
|
+
# document facilities create a complete Formatting system.
|
61
|
+
#
|
62
|
+
# This part of Ruport is under active development. Please do feel free to
|
63
|
+
# submit feature requests or suggestions.
|
64
|
+
class Format
|
65
|
+
|
66
|
+
# To hook up a Format object to your current class, you need to pass it a
|
67
|
+
# binding. This way, when filters are being processed, they will be
|
68
|
+
# evaluated in the context of the object they are being called from, rather
|
69
|
+
# than within an instance of Format.
|
70
|
+
#
|
71
|
+
def initialize(klass_binding)
|
72
|
+
@binding = klass_binding
|
73
|
+
end
|
74
|
+
|
75
|
+
# This is the text to be processed by the filters
|
76
|
+
attr_accessor :content
|
77
|
+
|
78
|
+
# This is the binding to the object Format is tied to
|
79
|
+
attr_accessor :binding
|
80
|
+
|
81
|
+
# Processes the ERB text in <tt>@content</tt> in the context
|
82
|
+
# of the object that Format is bound to.
|
83
|
+
def filter_erb
|
84
|
+
ERB.new(@content).result(@binding)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Processes the RedCloth text in <tt>@content</tt> in the context
|
88
|
+
# of the object that Format is bound to.
|
89
|
+
def filter_red_cloth
|
90
|
+
RedCloth.new(@content).to_html
|
91
|
+
end
|
92
|
+
|
93
|
+
# Processes the ruby code in <tt>@content</tt> in the context
|
94
|
+
# of the object that Format is bound to.
|
95
|
+
#
|
96
|
+
# (Does an eval on the binding)
|
97
|
+
def filter_ruby
|
98
|
+
eval(@content,@binding)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Takes a name and a block and creates a filter method
|
102
|
+
# This will define methods in the form of
|
103
|
+
# <tt>Format#filter_my_filter_name</tt>.
|
104
|
+
#
|
105
|
+
# Example:
|
106
|
+
#
|
107
|
+
# Format.register_filter :no_ohz do
|
108
|
+
# content.gsub(/O/i,"")
|
109
|
+
# end
|
110
|
+
def Format.register_filter(name,&filter_proc)
|
111
|
+
define_method "filter_#{name}".to_sym, &filter_proc
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|