libertree-model 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/lib/libertree/compat.rb +29 -0
  3. data/lib/libertree/console.rb +17 -0
  4. data/lib/libertree/db.rb +37 -0
  5. data/lib/libertree/embedder.rb +69 -0
  6. data/lib/libertree/embedding/custom-providers.rb +148 -0
  7. data/lib/libertree/model/account-settings.rb +14 -0
  8. data/lib/libertree/model/account.rb +487 -0
  9. data/lib/libertree/model/chat-message.rb +75 -0
  10. data/lib/libertree/model/comment-like.rb +68 -0
  11. data/lib/libertree/model/comment.rb +164 -0
  12. data/lib/libertree/model/contact-list.rb +81 -0
  13. data/lib/libertree/model/embed-cache.rb +6 -0
  14. data/lib/libertree/model/file.rb +6 -0
  15. data/lib/libertree/model/forest.rb +82 -0
  16. data/lib/libertree/model/group-member.rb +13 -0
  17. data/lib/libertree/model/group.rb +47 -0
  18. data/lib/libertree/model/has-display-text.rb +34 -0
  19. data/lib/libertree/model/has-searchable-text.rb +19 -0
  20. data/lib/libertree/model/ignored-member.rb +13 -0
  21. data/lib/libertree/model/invitation.rb +6 -0
  22. data/lib/libertree/model/is-remote-or-local.rb +30 -0
  23. data/lib/libertree/model/job.rb +93 -0
  24. data/lib/libertree/model/member.rb +222 -0
  25. data/lib/libertree/model/message.rb +154 -0
  26. data/lib/libertree/model/node.rb +22 -0
  27. data/lib/libertree/model/node_affiliation.rb +14 -0
  28. data/lib/libertree/model/node_subscription.rb +23 -0
  29. data/lib/libertree/model/notification.rb +71 -0
  30. data/lib/libertree/model/pool-post.rb +17 -0
  31. data/lib/libertree/model/pool.rb +172 -0
  32. data/lib/libertree/model/post-hidden.rb +21 -0
  33. data/lib/libertree/model/post-like.rb +72 -0
  34. data/lib/libertree/model/post-revision.rb +9 -0
  35. data/lib/libertree/model/post.rb +735 -0
  36. data/lib/libertree/model/profile.rb +25 -0
  37. data/lib/libertree/model/remote-storage-connection.rb +6 -0
  38. data/lib/libertree/model/river.rb +249 -0
  39. data/lib/libertree/model/server.rb +35 -0
  40. data/lib/libertree/model/session-account.rb +10 -0
  41. data/lib/libertree/model/url-expansion.rb +6 -0
  42. data/lib/libertree/model.rb +48 -0
  43. data/lib/libertree/query.rb +167 -0
  44. data/lib/libertree/render.rb +28 -0
  45. metadata +198 -0
@@ -0,0 +1,735 @@
1
+ # encoding: utf-8
2
+
3
+ require 'date'
4
+ require 'set'
5
+ require_relative '../embedder'
6
+
7
+ module Libertree
8
+ class VisibilityExpansionError < StandardError
9
+ end
10
+
11
+ module Model
12
+ class Post < Sequel::Model(:posts)
13
+ VISIBILITY_RANK = {
14
+ 'tree' => 0,
15
+ 'forest' => 1,
16
+ 'internet' => 2,
17
+ }
18
+
19
+ include IsRemoteOrLocal
20
+ extend HasSearchableText
21
+ include HasDisplayText
22
+
23
+ def before_create
24
+ self.hashtags = self.extract_hashtags
25
+ super
26
+ end
27
+
28
+ def after_create
29
+ super
30
+ if self.local? && self.distribute?
31
+ Libertree::Model::Job.create_for_forests(
32
+ {
33
+ task: 'request:POST',
34
+ params: { 'post_id' => self.id, }
35
+ },
36
+ *self.forests
37
+ )
38
+ end
39
+ Libertree::Embedder.autoembed(self.text)
40
+ self.notify_mentioned
41
+ self.notify_group_members
42
+ end
43
+
44
+ def before_update
45
+ self.hashtags = self.extract_hashtags
46
+ super
47
+ end
48
+
49
+ def after_update
50
+ super
51
+ has_distributable_difference = (
52
+ self.previous_changes.include?(:text) ||
53
+ self.previous_changes.include?(:visibility)
54
+ )
55
+
56
+ # TODO: deny change of visibility to 'tree' visibility?
57
+ # or trigger deletion on remotes?
58
+ if self.local? && self.distribute? && has_distributable_difference
59
+ Libertree::Model::Job.create_for_forests(
60
+ {
61
+ task: 'request:POST',
62
+ params: { 'post_id' => self.id, }
63
+ },
64
+ *self.forests
65
+ )
66
+ end
67
+ Libertree::Embedder.autoembed(self.text)
68
+ end
69
+
70
+ # TODO: DB: replace with association
71
+ def member
72
+ @member ||= Member[self.member_id]
73
+ end
74
+
75
+ def time_updated_overall
76
+ [time_commented, time_updated].compact.max
77
+ end
78
+
79
+ def read_by?(account)
80
+ DB.dbh[ "SELECT EXISTS( SELECT 1 FROM posts_read WHERE post_id = ? AND account_id = ? LIMIT 1 )", self.id, account.id ].single_value
81
+ end
82
+
83
+ def mark_as_read_by(account)
84
+ DB.dbh[ "SELECT mark_post_as_read_by( ?, ? )", self.id, account.id ].get
85
+ end
86
+
87
+ def mark_as_unread_by(account)
88
+ DB.dbh[ "DELETE FROM posts_read WHERE post_id = ? AND account_id = ?", self.id, account.id ].get
89
+ account.rivers.each do |river|
90
+ if river.should_contain? self
91
+ DB.dbh[ "INSERT INTO river_posts ( river_id, post_id ) VALUES ( ?, ? )", river.id, self.id ].get
92
+ end
93
+ end
94
+ end
95
+
96
+ def mark_as_unread_by_all( options = {} )
97
+ except_accounts = options.fetch(:except, [])
98
+ if except_accounts.any?
99
+ DB.dbh[:posts_read].where('post_id = ? AND NOT account_id IN ?', self.id, except_accounts.map(&:id)).delete
100
+ else
101
+ DB.dbh[ "DELETE FROM posts_read WHERE post_id = ?", self.id ].get
102
+ end
103
+
104
+ Libertree::Model::Job.create(
105
+ task: 'post:add-to-rivers',
106
+ params: { 'post_id' => self.id, }.to_json
107
+ )
108
+ end
109
+
110
+ def self.mark_all_as_read_by(account)
111
+ DB.dbh[ %{SELECT mark_all_posts_as_read_by(?)}, account.id ].get
112
+ end
113
+
114
+ # @param [Hash] opt options for restricting the comment set returned. See Comment.on_post .
115
+ def comments(opt = nil)
116
+ opt ||= {} # We put this here instead of in the method signature because sometimes nil is literally sent
117
+ Comment.on_post(self, opt)
118
+ end
119
+
120
+ def commented_on_by?(member)
121
+ DB.dbh[
122
+ %{
123
+ SELECT EXISTS(
124
+ SELECT 1
125
+ FROM comments
126
+ WHERE
127
+ post_id = ?
128
+ AND member_id = ?
129
+ )
130
+ },
131
+ self.id,
132
+ member.id
133
+ ].single_value
134
+ end
135
+
136
+ def likes
137
+ return @likes if @likes
138
+ @likes = PostLike.where(post_id: self.id).reverse_order(:id)
139
+ end
140
+
141
+ def notify_about_comment(comment)
142
+ notification_attributes = {
143
+ 'type' => 'comment',
144
+ 'comment_id' => comment.id,
145
+ }
146
+ accounts = comment.post.subscribers
147
+ if comment.member.account
148
+ accounts = accounts.select {|a| a.id != comment.member.account.id }
149
+ end
150
+ accounts.each do |account|
151
+ if ! comment.post.hidden_by?(account) && ! account.ignoring?(comment.member)
152
+ account.notify_about notification_attributes
153
+ end
154
+ end
155
+ end
156
+
157
+ def notify_about_like(like)
158
+ notification_attributes = {
159
+ 'type' => 'post-like',
160
+ 'post_like_id' => like.id,
161
+ }
162
+ local_post_author = like.post.member.account
163
+ like_author = like.member.account
164
+
165
+ if(
166
+ local_post_author &&
167
+ (!like_author || local_post_author.id != like_author.id) &&
168
+ ! local_post_author.ignoring?(like.member)
169
+ )
170
+ local_post_author.notify_about notification_attributes
171
+ end
172
+ end
173
+
174
+ def notify_mentioned
175
+ notification_attributes = {
176
+ 'type' => 'mention',
177
+ 'post_id' => self.id,
178
+ }
179
+
180
+ mentioned_accounts.each do |a|
181
+ if ! a.ignoring?(self.member)
182
+ a.notify_about notification_attributes
183
+ end
184
+ end
185
+ end
186
+
187
+ def notify_group_members
188
+ return if self.group.nil?
189
+
190
+ notification_attributes = {
191
+ 'type' => 'group-post',
192
+ 'post_id' => self.id,
193
+ }
194
+
195
+ self.group.members.each do |member|
196
+ if(
197
+ member.account &&
198
+ member != self.member &&
199
+ ! member.account.ignoring?(self.member)
200
+ )
201
+ member.account.notify_about notification_attributes
202
+ end
203
+ end
204
+ end
205
+
206
+ def mentioned_accounts
207
+ accounts = Set.new
208
+
209
+ # find all JIDs first
210
+ # TODO: username matching (left here for compatibility reasons) is deprecated
211
+ self.text_as_html.xpath('.//span[@rel="username"]').each do |n|
212
+ handle = n.content[1..-1].downcase
213
+ if account = Account[ username: handle ]
214
+ accounts << account
215
+ elsif member = Member.with_handle(handle)
216
+ accounts << member.account if member.account
217
+ end
218
+ end
219
+
220
+ # remove post author from result set
221
+ accounts.delete(self.member.account)
222
+ accounts.to_a
223
+ end
224
+
225
+ def before_destroy
226
+ if self.local? && self.distribute?
227
+ Libertree::Model::Job.create_for_forests(
228
+ {
229
+ task: 'request:POST-DELETE',
230
+ params: { 'post_id' => self.id, }
231
+ },
232
+ *self.forests
233
+ )
234
+ end
235
+ super
236
+ end
237
+
238
+ # TODO: the correct method to call is "destroy"
239
+ def delete
240
+ self.before_destroy
241
+ super
242
+ end
243
+
244
+ # NOTE: deletion is NOT distributed when force=true
245
+ def delete_cascade(force=false)
246
+ self.before_destroy unless force
247
+
248
+ DB.dbh[ "SELECT delete_cascade_post(?)", self.id ].get
249
+ end
250
+
251
+ def self.create(*args)
252
+ post = super
253
+ Libertree::Model::Job.create(
254
+ task: 'post:add-to-rivers',
255
+ params: { 'post_id' => post.id, }.to_json
256
+ )
257
+ if post.member.account
258
+ post.mark_as_read_by post.member.account
259
+ post.member.account.subscribe_to post
260
+ end
261
+ post
262
+ end
263
+
264
+ # This is a search, not a create
265
+ def like_by(member)
266
+ PostLike[ member_id: member.id, post_id: self.id ]
267
+ end
268
+ def liked_by?(member)
269
+ !! like_by(member)
270
+ end
271
+
272
+ # TODO: Optionally restrict by account, so as not to reveal too much to browser/client
273
+ # i.e. rivers not belonging to current account
274
+ def rivers_belonged_to(account = nil)
275
+ query_params = [self.id]
276
+
277
+ if account
278
+ account_clause = "AND r.account_id = ?"
279
+ query_params << account.id
280
+ end
281
+
282
+ River.s(
283
+ %{
284
+ SELECT
285
+ r.*
286
+ FROM
287
+ rivers r
288
+ , river_posts rp
289
+ WHERE
290
+ rp.river_id = r.id
291
+ AND rp.post_id = ?
292
+ #{ account_clause }
293
+ },
294
+ *query_params
295
+ )
296
+ end
297
+
298
+ def extract_hashtags
299
+ self.text_as_html.xpath('.//span[@rel="hashtag"]').map do |n|
300
+ n.content[1..-1] =~ /([\p{Word}_-]+)/i
301
+ $1.downcase if $1
302
+ end
303
+ end
304
+
305
+ def self.with_tag( opts = {} )
306
+ return [] if opts[:tag].nil? || opts[:tag].empty?
307
+ tags = Sequel.pg_array(Array(opts[:tag]).map(&:downcase))
308
+ time = Time.at( opts.fetch(:time, Time.now.to_f) ).strftime("%Y-%m-%d %H:%M:%S.%6N%z")
309
+ Post.s(%{SELECT * FROM tagged_posts(?, ?, ?, ?, ?)},
310
+ tags,
311
+ time,
312
+ opts[:newer],
313
+ opts[:order_by] == :comment,
314
+ opts.fetch(:limit, 30))
315
+ end
316
+
317
+ def subscribers
318
+ Account.s(%{
319
+ SELECT a.*
320
+ FROM accounts a, post_subscriptions ps
321
+ WHERE ps.post_id = ? AND a.id = ps.account_id}, self.id)
322
+ end
323
+
324
+ def revise(text_new, visibility = self.visibility)
325
+ if VISIBILITY_RANK[visibility] > VISIBILITY_RANK[self.visibility] && ! visibility_expandable?
326
+ raise VisibilityExpansionError
327
+ end
328
+
329
+ PostRevision.create(
330
+ post_id: self.id,
331
+ text: self.text
332
+ )
333
+ self.update(
334
+ text: text_new,
335
+ visibility: visibility,
336
+ time_updated: Time.now
337
+ )
338
+
339
+ mark_as_unread_by_all
340
+ end
341
+
342
+ private def visibility_expandable?
343
+ comments.empty? && likes.empty?
344
+ end
345
+
346
+ def hidden_by?(account)
347
+ DB.dbh[ "SELECT post_hidden_by_account(?, ?)", self.id, account.id ].single_value
348
+ end
349
+
350
+ def self.not_hidden_by(account, posts=self)
351
+ posts.
352
+ qualify.
353
+ left_outer_join(:posts_hidden,
354
+ :posts_hidden__post_id => :posts__id,
355
+ :posts_hidden__account_id => account.id).
356
+ where(:posts_hidden__post_id => nil)
357
+ end
358
+
359
+ def self.read_by(account, posts=self)
360
+ posts.
361
+ qualify.
362
+ join(:posts_read,
363
+ :posts_read__post_id => :posts__id,
364
+ :posts_read__account_id => account.id)
365
+ end
366
+
367
+ def self.unread_by(account, posts=self)
368
+ posts.
369
+ qualify.
370
+ left_outer_join(:posts_read,
371
+ :posts_read__post_id => :posts__id,
372
+ :posts_read__account_id => account.id).
373
+ where(:posts_read__post_id => nil)
374
+ end
375
+
376
+ def self.liked_by(member, posts=self)
377
+ posts.
378
+ qualify.
379
+ join(:post_likes,
380
+ :post_likes__post_id => :posts__id,
381
+ :post_likes__member_id => member.id)
382
+ end
383
+
384
+ def self.without_liked_by(member, posts=self)
385
+ posts.
386
+ qualify.
387
+ join(:post_likes,
388
+ :post_likes__post_id => :posts__id,
389
+ :post_likes__member_id => member.id)
390
+ end
391
+
392
+ def self.commented_on_by(member, posts=self)
393
+ posts.
394
+ where(:posts__id => Comment.
395
+ select(:post_id).
396
+ distinct(:post_id).
397
+ where(:member_id => member.id))
398
+ end
399
+
400
+ def self.without_commented_on_by(member, posts=self)
401
+ posts.
402
+ exclude(:posts__id => Comment.
403
+ select(:post_id).
404
+ distinct(:post_id).
405
+ where(:member_id => member.id))
406
+ end
407
+
408
+ def self.subscribed_to_by(account, posts=self)
409
+ posts.
410
+ qualify.
411
+ join(:post_subscriptions,
412
+ :post_subscriptions__post_id => :posts__id,
413
+ :post_subscriptions__account_id => account.id)
414
+ end
415
+
416
+ def self.without_subscribed_to_by(account, posts=self)
417
+ posts.
418
+ qualify.
419
+ left_outer_join(:post_subscriptions,
420
+ :post_subscriptions__post_id => :posts__id,
421
+ :post_subscriptions__account_id => account.id).
422
+ where(:post_subscriptions__post_id => nil)
423
+ end
424
+
425
+ def self.filter_by_query(parsed_query, account, posts=self)
426
+ flags = parsed_query['flag']
427
+ if flags
428
+ flags[:negations].each do |flag|
429
+ case flag
430
+ when 'tree'
431
+ posts = posts.exclude(:remote_id => nil)
432
+ when 'unread'
433
+ posts = self.read_by(account, posts)
434
+ when 'liked'
435
+ posts = self.without_liked_by(account.member, posts)
436
+ when 'commented'
437
+ posts = self.without_commented_on_by(account.member, posts)
438
+ when 'subscribed'
439
+ posts = self.without_subscribed_to_by(account, posts)
440
+ end
441
+ end
442
+
443
+ flags[:requirements].each do |flag|
444
+ case flag
445
+ when 'tree'
446
+ posts = posts.where(:remote_id => nil)
447
+ when 'unread'
448
+ posts = self.unread_by(account, posts)
449
+ when 'liked'
450
+ posts = self.liked_by(account.member, posts)
451
+ when 'commented'
452
+ posts = self.commented_on_by(account.member, posts)
453
+ when 'subscribed'
454
+ posts = self.subscribed_to_by(account, posts)
455
+ end
456
+ end
457
+
458
+ sets = flags[:regular].map do |flag|
459
+ case flag
460
+ when 'tree'
461
+ posts.where(:remote_id => nil)
462
+ when 'unread'
463
+ self.unread_by(account, posts)
464
+ when 'liked'
465
+ self.liked_by(account.member, posts)
466
+ when 'commented'
467
+ self.commented_on_by(account.member, posts)
468
+ when 'subscribed'
469
+ self.subscribed_to_by(account, posts)
470
+ end
471
+ end.compact
472
+
473
+ unless sets.empty?
474
+ posts = sets.reduce do |res, set|
475
+ res.union(set)
476
+ end
477
+ end
478
+ end
479
+
480
+ tags = parsed_query['tag']
481
+ if tags.values.flatten.count > 0
482
+ # Careful! Don't trust user input!
483
+ # remove any tag that includes array braces
484
+ excluded = tags[:negations].delete_if {|t| t =~ /[{}]/ }
485
+ required = tags[:requirements].delete_if {|t| t =~ /[{}]/ }
486
+ regular = tags[:regular].delete_if {|t| t =~ /[{}]/ }
487
+
488
+ posts = posts.exclude(Sequel.pg_array(:hashtags).cast('text[]').pg_array.overlaps(excluded)) unless excluded.empty?
489
+ posts = posts.where (Sequel.pg_array(:hashtags).cast('text[]').pg_array.contains(required)) unless required.empty?
490
+ posts = posts.where (Sequel.pg_array(:hashtags).cast('text[]').pg_array.overlaps(regular)) unless regular.empty?
491
+ end
492
+
493
+ phrases = parsed_query['phrase']
494
+ if phrases.values.flatten.count > 0
495
+ req_patterns = phrases[:requirements].map {|phrase| /(^|\b|\s)#{Regexp.escape(phrase)}(\b|\s|$)/ }
496
+ neg_patterns = phrases[:negations].map {|phrase| /(^|\b|\s)#{Regexp.escape(phrase)}(\b|\s|$)/ }
497
+ reg_patterns = phrases[:regular].map {|phrase| /(^|\b|\s)#{Regexp.escape(phrase)}(\b|\s|$)/ }
498
+
499
+ unless req_patterns.empty?
500
+ posts = posts.grep(:text, req_patterns, { all_patterns: true, case_insensitive: true })
501
+ end
502
+
503
+ unless neg_patterns.empty?
504
+ # NOTE: using Regexp.union results in a postgresql error when the phrase includes '#', or '?'
505
+ pattern = "(#{neg_patterns.map(&:source).join('|')})"
506
+ posts = posts.exclude(Sequel.ilike(:text, pattern))
507
+ end
508
+
509
+ unless reg_patterns.empty?
510
+ posts = posts.grep(:text, reg_patterns, { all_patterns: false, case_insensitive: true })
511
+ end
512
+ end
513
+
514
+ words = parsed_query['word']
515
+ if words.values.flatten.count > 0
516
+ # strip query characters
517
+ words.each_pair {|k,v| words[k].each {|word| word.gsub!(/[\(\)&|!]/, '')}}
518
+
519
+ # filter by simple terms first to avoid having to check so many posts
520
+ # TODO: prevent empty arguments to to_tsquery
521
+ posts = posts.where(%{to_tsvector('simple', text)
522
+ @@ (to_tsquery('simple', ?)
523
+ && to_tsquery('simple', ?)
524
+ && to_tsquery('simple', ?))},
525
+ words[:negations].map{|w| "!#{w}" }.join(' & '),
526
+ words[:requirements].join(' & '),
527
+ words[:regular].join(' | '))
528
+ end
529
+
530
+ { 'visibility' => :visibility,
531
+ 'from' => :member_id,
532
+ 'via' => :via,
533
+ }.each_pair do |key, column|
534
+ set = parsed_query[key]
535
+ if set.values.flatten.count > 0
536
+ excluded = set[:negations]
537
+ required = set[:requirements]
538
+ regular = set[:regular]
539
+
540
+ posts = posts.exclude(column => excluded) unless excluded.empty?
541
+ posts = posts.where(column => required) unless required.empty?
542
+
543
+ unless regular.empty?
544
+ posts = regular.
545
+ map {|value| posts.where(column => value)}.
546
+ reduce {|res, set| res.union(set)}
547
+ end
548
+ end
549
+ end
550
+
551
+ posts
552
+ end
553
+
554
+ def collected_by?(account)
555
+ DB.dbh[ "SELECT account_collected_post(?, ?)", account.id, self.id ].single_value
556
+ end
557
+
558
+ def to_hash
559
+ {
560
+ 'id' => self.id,
561
+ 'time_created' => self.time_created,
562
+ 'time_updated' => self.time_updated,
563
+ 'text' => self.text,
564
+ }
565
+ end
566
+
567
+ def v_internet?
568
+ self.visibility == 'internet'
569
+ end
570
+ def v_forest?
571
+ self.visibility == 'forest' || self.visibility == 'internet'
572
+ end
573
+ def distribute?
574
+ self.visibility != 'tree'
575
+ end
576
+
577
+ def self.as_nested_json(id)
578
+ post = self[id]
579
+ JSON[ post.to_json( :include => {
580
+ :member => {},
581
+ :likes => {
582
+ :include => {
583
+ :member => {}
584
+ }},
585
+ :comments => {
586
+ :include => {
587
+ :member => {},
588
+ :likes => {
589
+ :include => {
590
+ :member => {}
591
+ }
592
+ }
593
+ }
594
+ }
595
+ }) ]
596
+ end
597
+
598
+ # Expand and embed all associated records.
599
+ def self.get_full(id, viewing_account = nil)
600
+ post = self[id]
601
+ return unless post
602
+
603
+ # cache member records
604
+ members = Hash.new
605
+ members.default_proc = proc do |hash, key|
606
+ member = Member[ key ]
607
+ name = member.name_display
608
+ member.define_singleton_method(:name_display) { name }
609
+ hash[key] = member
610
+ end
611
+
612
+ post_likes = post.likes
613
+
614
+ like_proc = proc do |like|
615
+ like.define_singleton_method(:member) { members[like.member_id] }
616
+ like
617
+ end
618
+
619
+ get_comments = lambda do
620
+ comments = Comment.on_post(post, viewing_account: viewing_account)
621
+ comment_likes = CommentLike.where('comment_id IN ?', comments.map(&:id)).reduce({}) do |hash, like|
622
+ if hash[like.comment_id]
623
+ hash[like.comment_id] << like
624
+ else
625
+ hash[like.comment_id] = [like]
626
+ end
627
+ hash
628
+ end
629
+
630
+ comments.map do |comment|
631
+ likes = if comment_likes[comment.id]
632
+ comment_likes[comment.id].map{|l| like_proc.call(l)}
633
+ else
634
+ []
635
+ end
636
+
637
+ comment.define_singleton_method(:member) { members[comment.member_id] }
638
+ comment.define_singleton_method(:likes) { likes }
639
+ comment.define_singleton_method(:post) { post }
640
+
641
+ comment
642
+ end
643
+ end
644
+
645
+ comments = get_comments.call
646
+
647
+ # enhance post object with expanded associations
648
+ post.define_singleton_method(:member) { members[post.member_id] }
649
+ post.define_singleton_method(:likes) { post_likes.map{|l| like_proc.call(l)} }
650
+ post.define_singleton_method(:comments) {|opts={}|
651
+ if opts[:refresh_cache]
652
+ comments = get_comments.call
653
+ end
654
+
655
+ res = comments
656
+ if opts
657
+ res = res.find_all {|c| c.id >= opts[:from_id].to_i} if opts[:from_id]
658
+ res = res.find_all {|c| c.id < opts[:to_id].to_i} if opts[:to_id]
659
+ res = res.last(opts[:limit].to_i) if opts[:limit]
660
+ end
661
+ res
662
+ }
663
+
664
+ post
665
+ end
666
+
667
+ # @return [Post] the earliest Post which contains a URL that the
668
+ # prospective post text contains
669
+ # This method only searches recent posts, not necessarily every post in the DB.
670
+ def self.urls_already_posted?(prospective_post_text)
671
+ posts_found = []
672
+
673
+ prospective_post_text.scan(
674
+ %r{(?<=\]\()(https?://\S+?)(?=\))|(?:^|\b)(https?://\S+)(?=\s|$)}
675
+ ) do |match1, match2|
676
+ url = match1 || match2
677
+ posts = self.s(
678
+ %{
679
+ SELECT *
680
+ FROM (
681
+ SELECT *
682
+ FROM posts
683
+ ORDER BY id DESC
684
+ LIMIT 256
685
+ ) AS subquery
686
+ WHERE text ~ ( '(^|\\A|\\s)' || ? || '(\\)|\\Z|\\s|$)' )
687
+ OR text ~ ( '\\]\\(' || ? || '\\)' )
688
+ ORDER by time_created
689
+ },
690
+ Regexp.escape(url),
691
+ Regexp.escape(url)
692
+ )
693
+ if posts.count > 0 && posts.count < 4
694
+ posts_found << posts[0]
695
+ end
696
+ end
697
+
698
+ posts_found.sort_by(&:time_created)[0]
699
+ end
700
+
701
+ def pools_by_member(member_id)
702
+ Libertree::Model::Pool.qualify.
703
+ join(:pools_posts, :pools_posts__pool_id => :pools__id).
704
+ where(pools_posts__post_id: self.id, pools__member_id: member_id)
705
+ end
706
+
707
+ def update_collection_status_for_member(member_id, pool_ids)
708
+ # - ignore ids in pool_ids that are also in ids
709
+ # - remove: ids that are not in pool_ids but are in ids
710
+ # - add: ids that are in pool_ids but not in ids
711
+ ids = pools_by_member(member_id).map(&:id)
712
+
713
+ add_ids = pool_ids - ids
714
+ add_pools = Libertree::Model::Pool.where(member_id: member_id, id: add_ids)
715
+ add_pools.each {|pool| pool << self }
716
+
717
+ remove_ids = ids - pool_ids
718
+ remove_pools = Libertree::Model::Pool.where(member_id: member_id, id: remove_ids)
719
+ remove_pools.each {|pool| pool.remove_post self }
720
+
721
+ add_pools
722
+ end
723
+
724
+ def guid
725
+ server = self.member.server
726
+ origin = server ? server.domain : Server.own_domain
727
+ "xmpp:#{origin}?;node=/posts;item=#{self.public_id}"
728
+ end
729
+
730
+ def group
731
+ Libertree::Model::Group[self.group_id]
732
+ end
733
+ end
734
+ end
735
+ end