libertree-model 0.9.11
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/libertree/compat.rb +29 -0
- data/lib/libertree/console.rb +17 -0
- data/lib/libertree/db.rb +37 -0
- data/lib/libertree/embedder.rb +69 -0
- data/lib/libertree/embedding/custom-providers.rb +148 -0
- data/lib/libertree/model/account-settings.rb +14 -0
- data/lib/libertree/model/account.rb +487 -0
- data/lib/libertree/model/chat-message.rb +75 -0
- data/lib/libertree/model/comment-like.rb +68 -0
- data/lib/libertree/model/comment.rb +164 -0
- data/lib/libertree/model/contact-list.rb +81 -0
- data/lib/libertree/model/embed-cache.rb +6 -0
- data/lib/libertree/model/file.rb +6 -0
- data/lib/libertree/model/forest.rb +82 -0
- data/lib/libertree/model/group-member.rb +13 -0
- data/lib/libertree/model/group.rb +47 -0
- data/lib/libertree/model/has-display-text.rb +34 -0
- data/lib/libertree/model/has-searchable-text.rb +19 -0
- data/lib/libertree/model/ignored-member.rb +13 -0
- data/lib/libertree/model/invitation.rb +6 -0
- data/lib/libertree/model/is-remote-or-local.rb +30 -0
- data/lib/libertree/model/job.rb +93 -0
- data/lib/libertree/model/member.rb +222 -0
- data/lib/libertree/model/message.rb +154 -0
- data/lib/libertree/model/node.rb +22 -0
- data/lib/libertree/model/node_affiliation.rb +14 -0
- data/lib/libertree/model/node_subscription.rb +23 -0
- data/lib/libertree/model/notification.rb +71 -0
- data/lib/libertree/model/pool-post.rb +17 -0
- data/lib/libertree/model/pool.rb +172 -0
- data/lib/libertree/model/post-hidden.rb +21 -0
- data/lib/libertree/model/post-like.rb +72 -0
- data/lib/libertree/model/post-revision.rb +9 -0
- data/lib/libertree/model/post.rb +735 -0
- data/lib/libertree/model/profile.rb +25 -0
- data/lib/libertree/model/remote-storage-connection.rb +6 -0
- data/lib/libertree/model/river.rb +249 -0
- data/lib/libertree/model/server.rb +35 -0
- data/lib/libertree/model/session-account.rb +10 -0
- data/lib/libertree/model/url-expansion.rb +6 -0
- data/lib/libertree/model.rb +48 -0
- data/lib/libertree/query.rb +167 -0
- data/lib/libertree/render.rb +28 -0
- metadata +198 -0
@@ -0,0 +1,164 @@
|
|
1
|
+
require_relative '../embedder'
|
2
|
+
|
3
|
+
module Libertree
|
4
|
+
module Model
|
5
|
+
class Comment < Sequel::Model(:comments)
|
6
|
+
include IsRemoteOrLocal
|
7
|
+
extend HasSearchableText
|
8
|
+
include HasDisplayText
|
9
|
+
|
10
|
+
def after_create
|
11
|
+
super
|
12
|
+
|
13
|
+
if self.local? && self.post.distribute?
|
14
|
+
Libertree::Model::Job.create_for_forests(
|
15
|
+
{
|
16
|
+
task: 'request:COMMENT',
|
17
|
+
params: { 'comment_id' => self.id, }
|
18
|
+
},
|
19
|
+
*self.forests
|
20
|
+
)
|
21
|
+
end
|
22
|
+
Libertree::Embedder.autoembed(self.text)
|
23
|
+
end
|
24
|
+
|
25
|
+
# TODO: DB: association
|
26
|
+
def member
|
27
|
+
@member = Member[self.member_id]
|
28
|
+
end
|
29
|
+
|
30
|
+
# TODO: DB: association
|
31
|
+
def post
|
32
|
+
@post = Post[self.post_id]
|
33
|
+
end
|
34
|
+
|
35
|
+
def before_destroy
|
36
|
+
if self.post
|
37
|
+
remaining_comments = self.post.comments - [self]
|
38
|
+
self.post.time_commented = remaining_comments.map(&:time_created).max
|
39
|
+
end
|
40
|
+
|
41
|
+
if self.local? && self.post.distribute?
|
42
|
+
Libertree::Model::Job.create_for_forests(
|
43
|
+
{
|
44
|
+
task: 'request:COMMENT-DELETE',
|
45
|
+
params: { 'comment_id' => self.id, }
|
46
|
+
},
|
47
|
+
*self.forests
|
48
|
+
)
|
49
|
+
end
|
50
|
+
super
|
51
|
+
end
|
52
|
+
|
53
|
+
# TODO: the correct method to call is "destroy"
|
54
|
+
def delete
|
55
|
+
self.before_destroy
|
56
|
+
super
|
57
|
+
end
|
58
|
+
|
59
|
+
# NOTE: deletion is NOT distributed when force=true
|
60
|
+
def delete_cascade(force=false)
|
61
|
+
self.before_destroy unless force
|
62
|
+
DB.dbh[ "SELECT delete_cascade_comment(?)", self.id ].get
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.create(*args)
|
66
|
+
comment = super
|
67
|
+
account = comment.member.account
|
68
|
+
post = comment.post
|
69
|
+
|
70
|
+
post.time_commented = comment.time_created
|
71
|
+
post.mark_as_unread_by_all except: [account]
|
72
|
+
if account
|
73
|
+
post.mark_as_read_by account
|
74
|
+
account.subscribe_to comment.post
|
75
|
+
end
|
76
|
+
post.notify_about_comment comment
|
77
|
+
post.save
|
78
|
+
|
79
|
+
comment
|
80
|
+
end
|
81
|
+
|
82
|
+
def likes
|
83
|
+
@likes ||= CommentLike.where(comment_id: self.id).reverse_order(:id)
|
84
|
+
end
|
85
|
+
|
86
|
+
def notify_about_like(like)
|
87
|
+
notification_attributes = {
|
88
|
+
'type' => 'comment-like',
|
89
|
+
'comment_like_id' => like.id,
|
90
|
+
}
|
91
|
+
local_comment_author = like.comment.member.account
|
92
|
+
like_author = like.member.account
|
93
|
+
|
94
|
+
if(
|
95
|
+
local_comment_author &&
|
96
|
+
(!like_author || local_comment_author.id != like_author.id) &&
|
97
|
+
! local_comment_author.ignoring?(like.member)
|
98
|
+
)
|
99
|
+
local_comment_author.notify_about notification_attributes
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def like_by(member)
|
104
|
+
# take advantage of cached self.likes
|
105
|
+
if self.likes.is_a? Array
|
106
|
+
self.likes.find {|like| like.member.id == member.id}
|
107
|
+
else
|
108
|
+
CommentLike[ member_id: member.id, comment_id: self.id ]
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# overriding method from IsRemoteOrLocal
|
113
|
+
def forests
|
114
|
+
if self.post.remote?
|
115
|
+
self.post.server.forests
|
116
|
+
else
|
117
|
+
Libertree::Model::Forest.all_local_is_member
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def to_hash
|
122
|
+
{
|
123
|
+
'id' => self.id,
|
124
|
+
'time_created' => self.time_created,
|
125
|
+
'time_updated' => self.time_updated,
|
126
|
+
'text' => self.text,
|
127
|
+
'post_id' => self.post_id,
|
128
|
+
}
|
129
|
+
end
|
130
|
+
|
131
|
+
# TODO: When more visibilities come, restrict this result set by visibility
|
132
|
+
def self.comments_since_id(comment_id)
|
133
|
+
self.where{ id > comment_id }.order(:id)
|
134
|
+
end
|
135
|
+
|
136
|
+
# @param [Hash] opt options for restricting the comment set returned
|
137
|
+
# @option opts [Fixnum] :from_id Only return comments with id greater than or equal to this id
|
138
|
+
# @option opts [Fixnum] :to_id Only return comments with id less than this id
|
139
|
+
# @option opts [Account] :viewing_account An account to use to hide comments by ignored members
|
140
|
+
def self.on_post(post, opt = {})
|
141
|
+
res = Comment.where(post_id: post.id)
|
142
|
+
if opt[:viewing_account]
|
143
|
+
# Array() because sometimes opt[:viewing_account] is a strange nil-like object for some reason.
|
144
|
+
# Ramaze weirdness?
|
145
|
+
res = res.exclude(member_id: Array(opt[:viewing_account].ignored_members).map(&:id))
|
146
|
+
end
|
147
|
+
|
148
|
+
# reverse ordering is required in order to get the *last* n
|
149
|
+
# comments, rather than the first few when using :limit
|
150
|
+
res = res.reverse_order(:id)
|
151
|
+
res = res.where{ id >= opt[:from_id].to_i } if opt[:from_id]
|
152
|
+
res = res.where{ id < opt[:to_id].to_i } if opt[:to_id]
|
153
|
+
res = res.limit(opt[:limit].to_i) if opt[:limit]
|
154
|
+
res.all.reverse
|
155
|
+
end
|
156
|
+
|
157
|
+
def guid
|
158
|
+
server = self.member.server
|
159
|
+
origin = server ? server.domain : Server.own_domain
|
160
|
+
"xmpp:#{origin}?;node=/comments;item=#{self.public_id}"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
class ContactList < Sequel::Model(:contact_lists)
|
4
|
+
def account
|
5
|
+
@account ||= Account[self.account_id]
|
6
|
+
end
|
7
|
+
|
8
|
+
def members
|
9
|
+
return @members if @members
|
10
|
+
|
11
|
+
@members = Member.s(
|
12
|
+
%{
|
13
|
+
SELECT
|
14
|
+
m.*
|
15
|
+
FROM
|
16
|
+
contact_lists_members clm
|
17
|
+
, members m
|
18
|
+
WHERE
|
19
|
+
clm.contact_list_id = ?
|
20
|
+
AND m.id = clm.member_id
|
21
|
+
},
|
22
|
+
self.id
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
def member_ids
|
27
|
+
Libertree::DB.dbh[:contact_lists_members].
|
28
|
+
select(:member_id).
|
29
|
+
where(:contact_list_id => self.id).
|
30
|
+
all.
|
31
|
+
flat_map(&:values)
|
32
|
+
end
|
33
|
+
|
34
|
+
def members=(arg)
|
35
|
+
DB.dbh.transaction do
|
36
|
+
DB.dbh[ "DELETE FROM contact_lists_members WHERE contact_list_id = ?", self.id ].get
|
37
|
+
Array(arg).each do |member_id_s|
|
38
|
+
DB.dbh[ "INSERT INTO contact_lists_members ( contact_list_id, member_id ) VALUES ( ?, ? )", self.id, member_id_s.to_i ].get
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def delete_cascade
|
44
|
+
DB.dbh[ "SELECT delete_cascade_contact_list(?)", self.id ].get
|
45
|
+
end
|
46
|
+
|
47
|
+
def <<(member)
|
48
|
+
# refuse to add anything that's not a Member
|
49
|
+
return unless member.is_a? Member
|
50
|
+
|
51
|
+
Libertree::DB.dbh.transaction do
|
52
|
+
unless self.member_ids.include?(member.id)
|
53
|
+
Libertree::DB.dbh[:contact_lists_members].
|
54
|
+
insert(contact_list_id: self.id, member_id: member.id)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# refresh any river containing a reference to this contact list
|
60
|
+
def refresh_rivers
|
61
|
+
rivers = account.rivers.select do |r|
|
62
|
+
vals = r.parsed_query['contact-list'].values.flatten(1)
|
63
|
+
! vals.empty? && vals.map(&:first).include?(self.id)
|
64
|
+
end
|
65
|
+
|
66
|
+
# refresh rivers in background jobs
|
67
|
+
rivers.each do |river|
|
68
|
+
if ! river.appended_to_all
|
69
|
+
Libertree::Model::Job.create(
|
70
|
+
task: 'river:refresh',
|
71
|
+
params: {
|
72
|
+
'river_id' => river.id,
|
73
|
+
}.to_json
|
74
|
+
)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
class Forest < Sequel::Model(:forests)
|
4
|
+
def trees
|
5
|
+
Server.s(
|
6
|
+
%{
|
7
|
+
SELECT
|
8
|
+
s.*
|
9
|
+
FROM
|
10
|
+
forests_servers fs
|
11
|
+
, servers s
|
12
|
+
WHERE
|
13
|
+
fs.forest_id = ?
|
14
|
+
AND s.id = fs.server_id
|
15
|
+
},
|
16
|
+
self.id
|
17
|
+
)
|
18
|
+
end
|
19
|
+
alias :servers :trees
|
20
|
+
|
21
|
+
def add(server)
|
22
|
+
DB.dbh[
|
23
|
+
%{
|
24
|
+
INSERT INTO forests_servers (
|
25
|
+
forest_id, server_id
|
26
|
+
) SELECT
|
27
|
+
?, ?
|
28
|
+
WHERE NOT EXISTS(
|
29
|
+
SELECT 1
|
30
|
+
FROM forests_servers fs2
|
31
|
+
WHERE
|
32
|
+
fs2.forest_id = ?
|
33
|
+
AND fs2.server_id = ?
|
34
|
+
)
|
35
|
+
},
|
36
|
+
self.id,
|
37
|
+
server.id,
|
38
|
+
self.id,
|
39
|
+
server.id
|
40
|
+
].get
|
41
|
+
end
|
42
|
+
|
43
|
+
def remove(server)
|
44
|
+
DB.dbh[ "DELETE FROM forests_servers WHERE forest_id = ? AND server_id = ?", self.id, server.id ].get
|
45
|
+
end
|
46
|
+
|
47
|
+
def local?
|
48
|
+
! origin_server_id
|
49
|
+
end
|
50
|
+
def self.all_local_is_member
|
51
|
+
where local_is_member: true
|
52
|
+
end
|
53
|
+
|
54
|
+
def origin
|
55
|
+
Server[origin_server_id]
|
56
|
+
end
|
57
|
+
|
58
|
+
def local_is_member?
|
59
|
+
local_is_member
|
60
|
+
end
|
61
|
+
|
62
|
+
# @param [Array(String)] domains
|
63
|
+
# @return [Array(Model::Server)] any new Server records that were created
|
64
|
+
def set_trees_by_domain(domains)
|
65
|
+
DB.dbh[ "DELETE FROM forests_servers WHERE forest_id = ?", self.id ].get
|
66
|
+
new_trees = []
|
67
|
+
|
68
|
+
domains.each do |domain|
|
69
|
+
tree = Model::Server[domain: domain]
|
70
|
+
if tree.nil?
|
71
|
+
tree = Model::Server.create(domain: domain)
|
72
|
+
new_trees << tree
|
73
|
+
end
|
74
|
+
|
75
|
+
self.add tree
|
76
|
+
end
|
77
|
+
|
78
|
+
new_trees
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
class Group < Sequel::Model(:groups)
|
4
|
+
def after_create
|
5
|
+
super
|
6
|
+
# Add creator of the group to the group
|
7
|
+
Libertree::Model::GroupMember.create(group_id: self.id, member_id: self.admin_member_id)
|
8
|
+
end
|
9
|
+
|
10
|
+
def add_member(member)
|
11
|
+
Libertree::Model::GroupMember.create(group_id: self.id, member_id: member.id)
|
12
|
+
end
|
13
|
+
|
14
|
+
def remove_member(member)
|
15
|
+
self.group_member(member).delete
|
16
|
+
end
|
17
|
+
|
18
|
+
def member?(member)
|
19
|
+
self.group_member(member).any?
|
20
|
+
end
|
21
|
+
|
22
|
+
def members
|
23
|
+
Libertree::Model::GroupMember.where(group_id: self.id).map { |gm| gm.member }
|
24
|
+
end
|
25
|
+
|
26
|
+
def posts( opts = {} )
|
27
|
+
time = Time.at(
|
28
|
+
opts.fetch(:time, Time.now.to_f)
|
29
|
+
).strftime("%Y-%m-%d %H:%M:%S.%6N%z")
|
30
|
+
|
31
|
+
Post.s(
|
32
|
+
"SELECT * FROM posts_in_group(?,?,?,?,?,?)",
|
33
|
+
self.id,
|
34
|
+
opts.fetch(:viewer_account_id),
|
35
|
+
time,
|
36
|
+
opts[:newer],
|
37
|
+
opts[:order_by] == :comment,
|
38
|
+
opts.fetch(:limit, 30)
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
def group_member(member)
|
43
|
+
Libertree::Model::GroupMember.where(group_id: self.id, member_id: member.id)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'libertree/render'
|
2
|
+
|
3
|
+
module Libertree
|
4
|
+
module Model
|
5
|
+
# Provides a "glimpse" instance method to objects that have a "text" field.
|
6
|
+
module HasDisplayText
|
7
|
+
def glimpse( length = 60 )
|
8
|
+
set_without_blockquotes = text_to_nodeset
|
9
|
+
set_without_blockquotes.xpath('.//blockquote').each(&:remove)
|
10
|
+
plain_text = set_without_blockquotes.inner_text.strip
|
11
|
+
|
12
|
+
if plain_text.empty?
|
13
|
+
plain_text = text_to_nodeset.inner_text.strip
|
14
|
+
end
|
15
|
+
|
16
|
+
plain_text = plain_text.gsub("\n", ' ')
|
17
|
+
snippet = plain_text[0...length]
|
18
|
+
if plain_text.length > length
|
19
|
+
snippet += '...'
|
20
|
+
end
|
21
|
+
|
22
|
+
snippet
|
23
|
+
end
|
24
|
+
|
25
|
+
def text_as_html
|
26
|
+
Render.to_html_nodeset(self.text)
|
27
|
+
end
|
28
|
+
|
29
|
+
def text_to_nodeset
|
30
|
+
Render.to_html_nodeset(self.text, [:no_images, :filter_html])
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
# Provides a "search" class method to a class that has a "text" field
|
4
|
+
# and a time_created field.
|
5
|
+
module HasSearchableText
|
6
|
+
def search(q, limit = 42, exact=true)
|
7
|
+
if exact
|
8
|
+
dict = 'simple'
|
9
|
+
else
|
10
|
+
dict = 'english'
|
11
|
+
end
|
12
|
+
self.where("(to_tsvector('simple', text) || to_tsvector('english', text)) @@ plainto_tsquery('#{dict}', ?)", q).
|
13
|
+
reverse_order(:time_created).
|
14
|
+
limit(limit.to_i).
|
15
|
+
all
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
module IsRemoteOrLocal
|
4
|
+
def public_id
|
5
|
+
self.remote_id || self.id
|
6
|
+
end
|
7
|
+
|
8
|
+
def remote?
|
9
|
+
!! remote_id
|
10
|
+
end
|
11
|
+
|
12
|
+
def local?
|
13
|
+
! remote_id
|
14
|
+
end
|
15
|
+
|
16
|
+
def server
|
17
|
+
@server ||= self.member.server
|
18
|
+
end
|
19
|
+
|
20
|
+
def forests
|
21
|
+
if self.remote?
|
22
|
+
self.server.forests
|
23
|
+
else
|
24
|
+
Libertree::Model::Forest.all_local_is_member
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
@@ -0,0 +1,93 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
module Libertree
|
5
|
+
module Model
|
6
|
+
class Job < Sequel::Model(:jobs)
|
7
|
+
MAX_TRIES = 48
|
8
|
+
RETRY_FACTOR = 0.2
|
9
|
+
|
10
|
+
def params
|
11
|
+
if val = super
|
12
|
+
JSON.parse val
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def retry!
|
17
|
+
self.pid = self.time_started = self.time_finished = nil
|
18
|
+
self.time_to_start = Time.now
|
19
|
+
self.tries = 0
|
20
|
+
self.save
|
21
|
+
end
|
22
|
+
|
23
|
+
# First parameter can be a Forest Array.
|
24
|
+
# Otherwise, assumed to create for all member forests.
|
25
|
+
def self.create_for_forests(create_args, *forests)
|
26
|
+
if forests.empty?
|
27
|
+
forests = Forest.all_local_is_member
|
28
|
+
end
|
29
|
+
|
30
|
+
trees = Set.new
|
31
|
+
forests.each do |f|
|
32
|
+
if f.local_is_member?
|
33
|
+
trees += f.trees
|
34
|
+
end
|
35
|
+
end
|
36
|
+
trees.each do |tree|
|
37
|
+
params = ( create_args[:params] || create_args['params'] || Hash.new )
|
38
|
+
params['server_id'] = tree.id
|
39
|
+
Libertree::Model::Job.create(
|
40
|
+
task: create_args[:task],
|
41
|
+
params: params.to_json
|
42
|
+
)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [Job] nil if no job was reserved
|
47
|
+
def self.reserve(tasks)
|
48
|
+
job = self.where("task IN ? AND pid IS NULL AND tries < #{MAX_TRIES} AND time_to_start <= NOW()", tasks).order(:time_to_start).limit(1).first
|
49
|
+
return nil if job.nil?
|
50
|
+
|
51
|
+
self.where({ id: job.id, pid: nil }).
|
52
|
+
update({ pid: Process.pid, time_started: Time.now })
|
53
|
+
|
54
|
+
job = Job[job.id]
|
55
|
+
if job.pid == Process.pid
|
56
|
+
job
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def unreserve
|
61
|
+
new_tries = self.tries+1
|
62
|
+
self.update(
|
63
|
+
time_started: nil,
|
64
|
+
pid: nil,
|
65
|
+
tries: new_tries,
|
66
|
+
time_to_start: Time.now + 60 * Math::E**(new_tries * RETRY_FACTOR)
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.pending_where(*args)
|
71
|
+
query = args[0]
|
72
|
+
params = args[1..-1]
|
73
|
+
|
74
|
+
self.where(
|
75
|
+
query + %{
|
76
|
+
AND time_finished IS NULL
|
77
|
+
AND tries < ?
|
78
|
+
},
|
79
|
+
*params,
|
80
|
+
MAX_TRIES
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
def self.unfinished(task=nil)
|
85
|
+
if task
|
86
|
+
self.where("task = ? AND time_finished IS NULL", task).all
|
87
|
+
else
|
88
|
+
self.where("time_finished IS NULL").all
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|