pfeed 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +20 -0
- data/LICENSE.txt +20 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +115 -0
- data/README.rdoc +231 -0
- data/Rakefile +44 -0
- data/VERSION +1 -0
- data/app/models/pfeed_delivery.rb +4 -0
- data/app/models/pfeed_item.rb +172 -0
- data/app/models/pfeeds/user_updated_attribute.rb +14 -0
- data/app/views/pfeeds/_pfeed.html.erb +14 -0
- data/app/views/pfeeds/_pfeed_item.html.erb +3 -0
- data/app/views/pfeeds/_user_updated_attribute.html.erb +4 -0
- data/db/migrate/0000_create_pfeed_items.rb +18 -0
- data/db/migrate/0001_create_pfeed_deliveries.rb +15 -0
- data/init.rb +18 -0
- data/install.rb +1 -0
- data/lib/generator/pfeed_customization/USAGE +10 -0
- data/lib/generator/pfeed_customization/pfeed_customization_generator.rb +29 -0
- data/lib/generator/pfeed_customization/templates/pfeed_model.rb +5 -0
- data/lib/generator/pfeed_customization/templates/pfeed_view.html.erb +5 -0
- data/lib/pfeed.rb +29 -0
- data/lib/pfeed/pfeed.rb +102 -0
- data/lib/pfeed/pfeed_utils.rb +21 -0
- data/lib/pfeed_utils.rb +21 -0
- data/lib/tasks/pfeed.rake +54 -0
- data/pfeed.gemspec +93 -0
- data/pfeed/.document +5 -0
- data/pfeed/Gemfile +13 -0
- data/pfeed/LICENSE.txt +20 -0
- data/pfeed/Rakefile +53 -0
- data/pfeed/test/helper.rb +18 -0
- data/pfeed/test/test_pfeed.rb +7 -0
- data/test/bk_lib/pfeed_test.rb +57 -0
- data/test/bk_lib/pfeed_utils_test.rb +11 -0
- data/test/helper.rb +20 -0
- data/test/lib/pfeed_test.rb +57 -0
- data/test/lib/pfeed_utils_test.rb +11 -0
- data/test/test_helper.rb +71 -0
- data/test/test_pfeed.rb +9 -0
- data/uninstall.rb +1 -0
- metadata +164 -0
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
@@ -0,0 +1,172 @@
|
|
1
|
+
class PfeedItem < ActiveRecord::Base
|
2
|
+
|
3
|
+
serialize :data, Hash
|
4
|
+
serialize :participants, Array
|
5
|
+
|
6
|
+
belongs_to :originator, :polymorphic => true
|
7
|
+
belongs_to :participant, :polymorphic => true
|
8
|
+
|
9
|
+
has_many :pfeed_deliveries, :dependent => :destroy
|
10
|
+
|
11
|
+
attr_accessor :temp_references # this is an temporary Hash to hold references to temporary Objects
|
12
|
+
|
13
|
+
def self.log(ar_obj,method_name,method_name_in_past_tense,returned_result,*args_supplied_to_method,&block_supplied_to_method)
|
14
|
+
|
15
|
+
#puts "#{ar_obj.class.to_s},#{method_name},#{method_name_in_past_tense},#{returned_result},#{args_supplied_to_method.length}"
|
16
|
+
|
17
|
+
# optional :if => :test, or :unless => :test
|
18
|
+
if if_cond = ar_obj.pfeed_options[:if]
|
19
|
+
return unless ar_obj.send(if_cond)
|
20
|
+
elsif unless_cond = ar_obj.pfeed_options[:unless]
|
21
|
+
return if ar_obj.send(unless_cond)
|
22
|
+
end
|
23
|
+
|
24
|
+
raise ArgumentError, "originator object must to be saved" if ar_obj.new_record?
|
25
|
+
|
26
|
+
temp_references = Hash.new
|
27
|
+
temp_references[:originator] = ar_obj
|
28
|
+
temp_references[:participant] = nil
|
29
|
+
temp_references[:participant] = args_supplied_to_method[0] if args_supplied_to_method && args_supplied_to_method.length >= 1 && args_supplied_to_method[0].class < ActiveRecord::Base
|
30
|
+
|
31
|
+
pfeed_class_name = "#{ar_obj.class.to_s.underscore}_#{method_name_in_past_tense}".camelize # may be I could use .classify
|
32
|
+
constructor_options = { :originator_id => temp_references[:originator].id , :originator_type => temp_references[:originator].class.to_s , :participant_id => (temp_references[:participant] ? temp_references[:participant].id : nil) , :participant_type => (temp_references[:participant] ? temp_references[:participant].class.to_s : nil) } # there is a reason why I didnt use {:originator => temp_references[:originator]} , if originator is new record it might get saved here un intentionally
|
33
|
+
|
34
|
+
|
35
|
+
p_item = new_pfeed_item(pfeed_class_name, constructor_options, temp_references)
|
36
|
+
p_item.pack_data(method_name,method_name_in_past_tense,returned_result,*args_supplied_to_method,&block_supplied_to_method)
|
37
|
+
|
38
|
+
|
39
|
+
p_item.save!
|
40
|
+
#puts "Trying to deliver to #{ar_obj} #{ar_obj.pfeed_audience_hash[method_name.to_sym]}"
|
41
|
+
p_item.attempt_delivery(ar_obj,ar_obj.pfeed_audience_hash[method_name.to_sym]) # attempting the delivery of the feed
|
42
|
+
end
|
43
|
+
|
44
|
+
@@dj = (defined? Delayed) == "constant" && (instance_methods.include? 'send_later') #this means Delayed_job exists , so make use of asynchronous delivery of pfeed
|
45
|
+
|
46
|
+
def attempt_delivery (ar_obj,method_name_arr)
|
47
|
+
return if method_name_arr.empty?
|
48
|
+
|
49
|
+
if @@dj
|
50
|
+
send_later(:deliver,ar_obj,method_name_arr)
|
51
|
+
else # regular instant delivery
|
52
|
+
send(:deliver,ar_obj,method_name_arr)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def deliver(ar_obj,method_name_arr)
|
57
|
+
method_name_arr.map { |method_name|
|
58
|
+
ar_obj.send(method_name)
|
59
|
+
}.flatten.uniq.map {|o| deliver_to(o) }.compact
|
60
|
+
end
|
61
|
+
|
62
|
+
def deliver_to(result_obj)
|
63
|
+
return nil unless result_obj != nil && begin
|
64
|
+
result_obj.is_pfeed_receiver
|
65
|
+
rescue NoMethodError
|
66
|
+
raise NoMethodError, "you must use the receives_pfeed macro for class: #{result_obj.class}"
|
67
|
+
end
|
68
|
+
|
69
|
+
if !result_obj.new_record?
|
70
|
+
delivery = PfeedDelivery.new
|
71
|
+
delivery.pfeed_item = self
|
72
|
+
delivery.pfeed_receiver = result_obj
|
73
|
+
delivery.save!
|
74
|
+
end
|
75
|
+
|
76
|
+
return result_obj
|
77
|
+
end
|
78
|
+
|
79
|
+
def accessible?
|
80
|
+
true
|
81
|
+
end
|
82
|
+
|
83
|
+
def view_template_name
|
84
|
+
"#{self.class.to_s.underscore}".split("/").last
|
85
|
+
end
|
86
|
+
|
87
|
+
def audience
|
88
|
+
# return list of objects to whom feed gets delivered
|
89
|
+
end
|
90
|
+
|
91
|
+
def pack_data(method_name,method_name_in_past_tense,returned_result,*args_supplied_to_method,&block_supplied_to_method)
|
92
|
+
self.data = {} if ! self.data
|
93
|
+
action_string = method_name_in_past_tense.humanize.downcase
|
94
|
+
hash_to_be_merged = {:action_string => action_string, :originator_identity => guess_identification(originator)}
|
95
|
+
|
96
|
+
if current_user = Thread.current[:current_user]
|
97
|
+
hash_to_be_merged.merge!(:current_user_identity => guess_identification(current_user))
|
98
|
+
end
|
99
|
+
|
100
|
+
self.data.merge! hash_to_be_merged
|
101
|
+
end
|
102
|
+
|
103
|
+
IDENTIFICATIONS = {}
|
104
|
+
def guess_identification(ar_obj)
|
105
|
+
if identifier = ar_obj.respond_to?(:pfeed_options) && ar_obj.pfeed_options[:pfeed_identification]
|
106
|
+
return ar_obj.send(identifier)
|
107
|
+
end
|
108
|
+
|
109
|
+
if attribute = IDENTIFICATIONS[ar_obj.class]
|
110
|
+
if (identi = ar_obj.read_attribute(attribute)).blank?
|
111
|
+
identi = ar_obj.send(attribute) rescue nil
|
112
|
+
end
|
113
|
+
return identi if identi
|
114
|
+
end
|
115
|
+
|
116
|
+
possible_attributes = ["username","login","name","company_name","first_name","last_name","login_name","login_id","given_name","nick_name","nick","short_name"]
|
117
|
+
|
118
|
+
possible_attributes = self.data[:config][:identifications] + possible_attributes if self.data[:config] && self.data[:config][:identifications] && self.data[:config][:identifications].is_a?(Array)
|
119
|
+
matched_name = ar_obj.attribute_names & possible_attributes # intersection of two sets
|
120
|
+
|
121
|
+
identi = nil
|
122
|
+
matched_name.each do |attribute|
|
123
|
+
result = ar_obj.read_attribute(attribute)
|
124
|
+
next unless result.present? && result.kind_of?(String)
|
125
|
+
IDENTIFICATIONS[ar_obj.class] = attribute
|
126
|
+
identi = result
|
127
|
+
break
|
128
|
+
end
|
129
|
+
|
130
|
+
if identi.blank?
|
131
|
+
possible_attributes.each do |attribute|
|
132
|
+
next unless ar_obj.respond_to? attribute
|
133
|
+
result = ar_obj.send(attribute) rescue nil
|
134
|
+
next unless result.present? && result.kind_of?(String)
|
135
|
+
IDENTIFICATIONS[ar_obj.class] = attribute
|
136
|
+
identi = result
|
137
|
+
break
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
identi = "#{ar_obj.class.to_s}(\##{ar_obj.id})" if identi.blank?
|
142
|
+
identi
|
143
|
+
end
|
144
|
+
|
145
|
+
# look for custom pfeed class, with or withour Pfeed:: prefix
|
146
|
+
CUSTOM_CLASSES = {}
|
147
|
+
def self.new_pfeed_item(pfeed_class_name, constructor_options, temp_references)
|
148
|
+
if (klass = CUSTOM_CLASSES[pfeed_class_name]).nil?
|
149
|
+
retried = false
|
150
|
+
begin
|
151
|
+
#puts "Attempting to create object of #{pfeed_class_name} "
|
152
|
+
klass = pfeed_class_name.constantize
|
153
|
+
(CUSTOM_CLASSES[pfeed_class_name] = klass).new(
|
154
|
+
constructor_options.merge(:temp_references => temp_references))
|
155
|
+
rescue NameError
|
156
|
+
unless retried
|
157
|
+
CUSTOM_CLASSES[pfeed_class_name] = false
|
158
|
+
retried = true
|
159
|
+
pfeed_class_name = "Pfeeds::"+pfeed_class_name
|
160
|
+
retry
|
161
|
+
end
|
162
|
+
PfeedItem.new(constructor_options)
|
163
|
+
end
|
164
|
+
else
|
165
|
+
if !klass
|
166
|
+
PfeedItem.new(constructor_options)
|
167
|
+
else
|
168
|
+
klass.new(constructor_options)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
class Pfeeds::UserUpdatedAttribute < PfeedItem
|
2
|
+
|
3
|
+
def pack_data(method_name,method_name_in_past_tense,returned_result,*args_supplied_to_method,&block_supplied_to_method)
|
4
|
+
super
|
5
|
+
self.data = {} if ! self.data
|
6
|
+
attribute_name = args_supplied_to_method[0].to_s.humanize
|
7
|
+
hash_to_be_merged = {:attribute_name => attribute_name}
|
8
|
+
|
9
|
+
self.data.merge! hash_to_be_merged
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
13
|
+
|
14
|
+
|
@@ -0,0 +1,14 @@
|
|
1
|
+
<ul class="pfeed_container">
|
2
|
+
<li>
|
3
|
+
<%=
|
4
|
+
begin
|
5
|
+
render(:partial => "pfeeds/#{pfeed.view_template_name}", :object => pfeed)
|
6
|
+
rescue
|
7
|
+
"<!-- error in #{pfeed.view_template_name}: #{$!.to_s.split("\n").join("-->\n<!--")} -->"
|
8
|
+
end
|
9
|
+
%>
|
10
|
+
</li>
|
11
|
+
</ul>
|
12
|
+
|
13
|
+
|
14
|
+
|
@@ -0,0 +1,4 @@
|
|
1
|
+
|
2
|
+
<font color="blue"><%= user_updated_attribute.guess_identification(user_updated_attribute.originator) %></font>
|
3
|
+
<%= user_updated_attribute.data[:action_string] %> <%= user_updated_attribute.data[:attribute_name] %>
|
4
|
+
about <%= time_ago_in_words(user_updated_attribute.created_at) %> ago
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class CreatePfeedItems < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :pfeed_items do |t|
|
4
|
+
t.string :type
|
5
|
+
t.integer :originator_id
|
6
|
+
t.string :originator_type
|
7
|
+
t.integer :participant_id
|
8
|
+
t.string :participant_type
|
9
|
+
t.text :data
|
10
|
+
t.datetime :expiry
|
11
|
+
t.timestamps
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.down
|
16
|
+
drop_table :pfeed_items
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class CreatePfeedDeliveries < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
|
4
|
+
create_table :pfeed_deliveries do |t|
|
5
|
+
t.integer :pfeed_receiver_id
|
6
|
+
t.string :pfeed_receiver_type
|
7
|
+
t.integer :pfeed_item_id
|
8
|
+
t.timestamps
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.down
|
13
|
+
drop_table :pfeed_deliveries
|
14
|
+
end
|
15
|
+
end
|
data/init.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# Author: Er Abhishek Parolkar
|
2
|
+
|
3
|
+
require File.dirname(__FILE__) + '/lib/pfeed'
|
4
|
+
require File.dirname(__FILE__) + '/lib/pfeed_utils'
|
5
|
+
ActiveRecord::Base.send(:include, ParolkarInnovationLab::SocialNet)
|
6
|
+
|
7
|
+
ActionController::Base.helper do
|
8
|
+
def pfeed_content(pfeed) #FIXME: interesting idea , but currently un-supported
|
9
|
+
controller.send('render_to_string',
|
10
|
+
:partial => "pfeeds/#{pfeed.view_template_name}.html.erb", :locals => {:object => pfeed})
|
11
|
+
end
|
12
|
+
|
13
|
+
def pfeed_item_url(pfeed_item)
|
14
|
+
# same as: polymorphic_url pfeed_item.originator
|
15
|
+
# but no need to query the database
|
16
|
+
send(pfeed_item.originator_type.underscore + '_url', pfeed_item.originator_id)
|
17
|
+
end
|
18
|
+
end
|
data/install.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
# Install hook code here
|
@@ -0,0 +1,29 @@
|
|
1
|
+
class PfeedCustomizationGenerator < Rails::Generators::Base
|
2
|
+
attr_reader :past_classname
|
3
|
+
attr_reader :past_varname
|
4
|
+
|
5
|
+
source_root File.expand_path('../templates', __FILE__)
|
6
|
+
argument :model, :type => :string, :required => true
|
7
|
+
argument :action_name, :type => :string,:required => true
|
8
|
+
|
9
|
+
def initialize_pfeed_customization
|
10
|
+
raise "#{model.to_s.classify} must define '#{action_name.underscore}' method" unless model.to_s.classify.constantize.methods.include? action_name.underscore
|
11
|
+
|
12
|
+
@model = model
|
13
|
+
@current_action = action_name.to_s.underscore
|
14
|
+
@past_action = ParolkarInnovationLab::SocialNet::PfeedUtils.attempt_pass_tense(@current_action)
|
15
|
+
@past = @model.downcase + '_' + @past_action
|
16
|
+
@past_classname = @model.capitalize + @past_action.capitalize
|
17
|
+
@past_varname = @model.downcase + '_' + @past_action.downcase
|
18
|
+
@model_filename = @past + '.rb'
|
19
|
+
@view_filename = '_' + @past + '.html.erb'
|
20
|
+
end
|
21
|
+
|
22
|
+
def manifest
|
23
|
+
template('pfeed_model.rb', "app/models/pfeeds/#{@model_filename}")
|
24
|
+
template('pfeed_view.html.erb', "app/views/pfeeds/#{@view_filename}")
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
end
|
data/lib/pfeed.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# Author: Er Abhishek Parolkar
|
2
|
+
if defined?(Rails)
|
3
|
+
module AuditTrail
|
4
|
+
require "rails"
|
5
|
+
require 'rubygems'
|
6
|
+
require 'rails'
|
7
|
+
require 'active_record'
|
8
|
+
require 'action_controller'
|
9
|
+
require 'active_support'
|
10
|
+
require 'pfeed/pfeed'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
|
16
|
+
ActiveRecord::Base.send(:include, ParolkarInnovationLab::SocialNet)
|
17
|
+
|
18
|
+
ActionController::Base.helper do
|
19
|
+
def pfeed_content(pfeed) #FIXME: interesting idea , but currently un-supported
|
20
|
+
controller.send('render_to_string',
|
21
|
+
:partial => "pfeeds/#{pfeed.view_template_name}.html.erb", :locals => {:object => pfeed})
|
22
|
+
end
|
23
|
+
|
24
|
+
def pfeed_item_url(pfeed_item)
|
25
|
+
# same as: polymorphic_url pfeed_item.originator
|
26
|
+
# but no need to query the database
|
27
|
+
send(pfeed_item.originator_type.underscore + '_url', pfeed_item.originator_id)
|
28
|
+
end
|
29
|
+
end
|
data/lib/pfeed/pfeed.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
#snippet: https://gist.github.com/89e92409ca9016d2d919
|
2
|
+
|
3
|
+
module ParolkarInnovationLab
|
4
|
+
module SocialNet
|
5
|
+
def self.included(base)
|
6
|
+
base.extend ParolkarInnovationLab::SocialNet::ClassMethods
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
|
11
|
+
def emits_pfeeds arg_hash # {:on => [] , :for => [:itself , :all_in_its_class], :identified_by => :name, :if => :passes_test?}
|
12
|
+
arg_hash.assert_valid_keys(:on,:for,:if,:unless,:identified_by)
|
13
|
+
[:on, :for].each do |argument|
|
14
|
+
raise ArgumentError, "Expected an argument: #{argument}" if !arg_hash[argument]
|
15
|
+
end
|
16
|
+
|
17
|
+
include ParolkarInnovationLab::SocialNet::InstanceMethods
|
18
|
+
|
19
|
+
method_name_array = [*arg_hash[:on]]
|
20
|
+
class_inheritable_hash :pfeed_audience_hash
|
21
|
+
|
22
|
+
method_name_array.each{|method_name| register_pfeed_audience(method_name,[*arg_hash[:for]].compact) }
|
23
|
+
|
24
|
+
class_inheritable_hash :pfeed_options
|
25
|
+
write_inheritable_hash :pfeed_options, arg_hash.slice(:if,:unless,:identified_by)
|
26
|
+
|
27
|
+
|
28
|
+
|
29
|
+
method_name_array.each { |method_name|
|
30
|
+
method, symbol = method_name.to_s.split /(\!|\?)/
|
31
|
+
symbol = '' if symbol.nil?
|
32
|
+
|
33
|
+
method_to_define = method + '_with_pfeed' + symbol
|
34
|
+
method_to_be_called = method + '_without_pfeed' + symbol
|
35
|
+
eval %[
|
36
|
+
|
37
|
+
module ::ParolkarInnovationLab::SocialNet::PfeedTemp::#{self.to_s}
|
38
|
+
def #{method_to_define}(*a, &b)
|
39
|
+
returned_result = #{method_to_be_called}(*a , &b)
|
40
|
+
method_name_in_past_tense = "#{ParolkarInnovationLab::SocialNet::PfeedUtils.attempt_pass_tense(method)}"
|
41
|
+
PfeedItem.log(self,"#{method_name}",method_name_in_past_tense,returned_result,*a,&b)
|
42
|
+
returned_result
|
43
|
+
end
|
44
|
+
end
|
45
|
+
]
|
46
|
+
|
47
|
+
}
|
48
|
+
|
49
|
+
#TODO : Pfeed.log(self,"#{method_name}",method_name_in_past_tense,returned_result,*a,*b) : this is to be done in a different thread in bg to boost performance & also needs exception handling such that parent call never breaks
|
50
|
+
|
51
|
+
include "::ParolkarInnovationLab::SocialNet::PfeedTemp::#{self.to_s}".constantize # why this? because "define_method((method + '_with_pfeed' + symbol).to_sym) do |*a , &b|" generates syntax error in ruby < 1.8.7
|
52
|
+
|
53
|
+
method_name_array.each { |method_name|
|
54
|
+
method, symbol = method_name.to_s.split /(\!|\?)/
|
55
|
+
symbol = '' if symbol.nil?
|
56
|
+
alias_method_chain (method + symbol), :pfeed
|
57
|
+
}
|
58
|
+
|
59
|
+
has_many :pfeed_items , :as => :originator , :dependent => :destroy #when originator is deleted the pfeed_items gets destroyed too
|
60
|
+
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
def receives_pfeed
|
65
|
+
has_many :pfeed_deliveries , :as => :pfeed_receiver
|
66
|
+
has_many :pfeed_inbox, :class_name => 'PfeedItem', :foreign_key => "pfeed_item_id" , :through => :pfeed_deliveries , :source => :pfeed_item
|
67
|
+
|
68
|
+
write_inheritable_attribute(:is_pfeed_receiver,true)
|
69
|
+
class_inheritable_reader :is_pfeed_receiver
|
70
|
+
end
|
71
|
+
|
72
|
+
def register_pfeed_audience(method_name,audience_arr)
|
73
|
+
write_inheritable_hash(:pfeed_audience_hash, { method_name.to_sym => audience_arr }) # this does a merge
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
module PfeedTemp
|
78
|
+
# Required for temporarily injecting new methods
|
79
|
+
end
|
80
|
+
module InstanceMethods
|
81
|
+
|
82
|
+
def itself
|
83
|
+
self
|
84
|
+
end
|
85
|
+
def all_in_its_class
|
86
|
+
self.class.find :all
|
87
|
+
end
|
88
|
+
|
89
|
+
def pfeed_recent_item_timestamp
|
90
|
+
self.pfeed_deliveries.last.created_at
|
91
|
+
rescue
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
#let private methods come here
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
|