Changesets can be listed by changeset number.
The Git repository is here.
- Revision:
- 10
- Log:
Checking in HEAD from RForum's SVN of 22-Jul-2006, 8pm (revision 906).
- Author:
- adh
- Date:
- Sat Jul 22 20:02:44 +0100 2006
- Size:
- 11228 Bytes
1 | require 'digest/md5' |
2 | require 'time' |
3 | require 'wraptools' |
4 | |
5 | # All posts in a topic are organized into a tree, stored as Nested Set. |
6 | # (see http://threebit.net/tutorials/nestedset/tutorial1.html) |
7 | # |
8 | # The l and r attributes are the nested set boundaries. |
9 | # |
10 | # Also, traditional tree-like structure of parent_id links is maintained, just in case |
11 | # the nested set should get corrupted. |
12 | # Nested Set is used because it provides faster reads at a price of slower updates. |
13 | |
14 | class Post < ActiveRecord::Base |
15 | belongs_to :topic |
16 | belongs_to :user |
17 | belongs_to :parent, :class_name => 'Post', :foreign_key => 'parent_id' |
18 | has_many :children, :class_name => 'Post', :foreign_key => 'parent_id' |
19 | has_many :post_votes, :dependent => true |
20 | has_many :attachments, :dependent => true |
21 | composed_of :guest, :mapping => [ %w(guest_name guest_name), %w(guest_email guest_email) ] |
22 | |
23 | # TODO: don't tokenize the id fields |
24 | acts_as_ferret :fields => [:subject, :text, :user_id, :forum_id, :deleted], :auto_index_update => false |
25 | |
26 | attr_accessor :new_attachment |
27 | |
28 | validates_length_of :text, :within => 2..50000, |
29 | :too_short => 'formerror_text_short', :too_long => 'formerror_text_long' |
30 | validates_length_of :subject, :within => 3..60, |
31 | :too_short => 'formerror_subject_short', :too_long => 'formerror_subject_long' |
32 | |
33 | validates_uniqueness_of :messageid |
34 | |
35 | include ErrorRaising |
36 | |
37 | |
38 | # CLASS METHODS |
39 | |
40 | # Find a post and all its parents. |
41 | def self.find_with_all_parents(id) |
42 | find_by_sql <<-EOL |
43 | SELECT p.* FROM posts p, posts c |
44 | WHERE c.l BETWEEN p.l AND p.r |
45 | AND c.id = #{id.to_i} |
46 | AND p.topic_id = c.topic_id |
47 | ORDER BY p.l |
48 | EOL |
49 | end |
50 | |
51 | # Find the <tt>number</tt> latest posts. |
52 | def self.find_latest(number) |
53 | Post.find_all "deleted = 0", 'created_at DESC, id DESC', number |
54 | end |
55 | |
56 | # Finds post with specified id, and adds data from corresponding USERS row |
57 | # into it. Views commonly need post with this data, so this method saves |
58 | # a database roundtrip. |
59 | def self.find_with_user_data(id) |
60 | query = <<-EOL |
61 | SELECT posts.*, |
62 | users.name AS user_name, |
63 | users.firstname AS user_firstname, |
64 | users.surname AS user_surname |
65 | FROM posts |
66 | LEFT JOIN users ON posts.user_id = users.id |
67 | WHERE posts.id = #{id.to_i} |
68 | EOL |
69 | find_first_by_sql(query) |
70 | end |
71 | |
72 | # Find all posts with references in a range of nntpids. |
73 | # TODO: move to forum.rb? |
74 | # FIXME: this method uses non-existant association 'post.references' |
75 | def self.find_in_nntp_range(forum_id, first, last) |
76 | query = " |
77 | SELECT posts.*,users.name AS user_name, |
78 | users.firstname AS user_firstname, |
79 | users.surname AS user_surname |
80 | FROM posts |
81 | LEFT JOIN topics ON posts.topic_id = topics.id |
82 | LEFT JOIN users ON posts.user_id = users.id |
83 | WHERE topics.forum_id = #{forum_id.to_i} |
84 | AND posts.nntpid BETWEEN #{first.to_i} AND #{last.to_i} |
85 | ORDER BY nntpid ASC |
86 | " |
87 | posts = find_by_sql(query) |
88 | |
89 | query = " |
90 | SELECT parent.messageid as refid, |
91 | child.id AS id |
92 | FROM posts parent, posts child |
93 | LEFT JOIN topics ON child.topic_id = topics.id |
94 | WHERE child.nntpid BETWEEN #{first.to_i} AND #{last.to_i} |
95 | AND parent.topic_id = child.topic_id |
96 | AND child.l BETWEEN parent.l AND parent.r |
97 | AND parent.id <> child.id |
98 | AND topics.forum_id = #{forum_id.to_i} |
99 | ORDER BY parent.l |
100 | " |
101 | |
102 | references = Hash.new |
103 | |
104 | find_by_sql(query).each do |ref| |
105 | unless ref.refid == nil |
106 | references[ref.id] ||= Array.new |
107 | references[ref.id] << ref.refid |
108 | end |
109 | end |
110 | |
111 | posts.each do |post| |
112 | if references[post.id] |
113 | post['references'] = references[post.id].inject('') { |refs, msgid| refs + "<#{msgid}> " }.strip |
114 | else |
115 | post['references'] = nil |
116 | end |
117 | end |
118 | |
119 | posts |
120 | end |
121 | |
122 | # Return an Array of posts matching the query string |
123 | def self.search(query, forums=[], number=1, offset=0) |
124 | if forums.size > 0 |
125 | forum_query = " +forum_id:(#{forums.join('|')}" |
126 | else |
127 | forum_query = "" |
128 | end |
129 | |
130 | self.find_by_contents(query + forum_query + ' +deleted:0', :first_doc => offset, :num_docs => number) |
131 | end |
132 | |
133 | # CALLBACKS |
134 | def before_validation |
135 | # Guest post |
136 | if self.user_id.nil? |
137 | unless self.guest_name.nil? |
138 | self.guest_name.strip! |
139 | self.guest_name = 'Guest' if self.guest_name.size == 0 |
140 | end |
141 | self.guest_email.strip! unless self.guest_email.nil? |
142 | else |
143 | self.guest_name = nil |
144 | self.guest_email = nil |
145 | end |
146 | self.text = Wraptools::wrap_ff(self.text, 72) unless self.text.nil? |
147 | self.subject.strip! unless self.subject.nil? |
148 | |
149 | # limit to 60 chars |
150 | if self.subject && self.subject.size > 60 |
151 | self.subject = self.subject[0, 60] |
152 | end |
153 | end |
154 | |
155 | def validate |
156 | # spam filter (only for forum posts) |
157 | if self.post_method != 'mail' |
158 | matches = RForum::CONFIG[:spam_filter_regex].match(self.subject.to_s + self.text.to_s) |
159 | if matches |
160 | errors.add('text', "Your post seems to contain Spam: \"#{matches[0]}\".") |
161 | end |
162 | end |
163 | |
164 | check_mandatory_atributes(:topic_id) |
165 | if self.parent_id and self.parent.topic_id != self.topic_id |
166 | raise ArgumentError.new("Post ##{self.parent_id} does not belong to topic #{self.topic_id}") |
167 | end |
168 | |
169 | if @new_attachment && @new_attachment.size > RForum::CONFIG[:max_attachment_size] |
170 | errors.add('new_attachment', :attachment_too_large) |
171 | end |
172 | |
173 | errors.add('text', :formerror_text_short) if text.nil? |
174 | errors.add('subject', :formerror_subject_short) if subject.nil? |
175 | |
176 | # anoymous user may be required specify a name and email address |
177 | if user_id.nil? and not RForum::CONFIG[:anon_posting_allowed] |
178 | errors.add_on_empty %w(guest_name guest_email) |
179 | if (guest_name and guest_name.size < 2) |
180 | errors.add('guest_name', :formerror_no_guest_name) |
181 | end |
182 | if (guest_email and not valid_email?(guest_email)) |
183 | errors.add('guest_email', :formerror_invalid_email) |
184 | end |
185 | end |
186 | |
187 | errors.add('text', :formerror_too_many_quoted_lines) if too_many_quoted_lines? |
188 | end |
189 | |
190 | def before_create |
191 | # Generate a Messageid if none is given |
192 | if self.messageid.nil? or self.messageid.to_s.strip.empty? |
193 | self.messageid = make_messageid |
194 | end |
195 | insert_into_nested_set |
196 | self.nntpid = self.topic.forum.get_next_free_nntpid |
197 | self.deleted = 0 |
198 | end |
199 | |
200 | def after_create |
201 | # send list mails |
202 | if RForum::CONFIG[:deliver_mail] and self.topic.forum.list_address and self.post_method != 'mail' |
203 | Mailer.deliver_ml_post(self) |
204 | end |
205 | |
206 | # notify users |
207 | self.topic.topic_subscriptions.each do |subscription| |
208 | subscription.do_notify(self) |
209 | end |
210 | end |
211 | |
212 | def before_destroy |
213 | transaction do |
214 | self.reload |
215 | # destroy all children |
216 | Post.destroy_all "topic_id = #{self.topic_id} AND parent_id = #{self.id}" |
217 | self.reload |
218 | move = 2 * (1 + ((self.r - self.l) / 2.0).floor) |
219 | Post.update_all "l = l - #{move}", "topic_id = #{self.topic_id} AND l > #{self.r}" |
220 | Post.update_all "r = r - #{move}", "topic_id = #{self.topic_id} AND r > #{self.r}" |
221 | end |
222 | end |
223 | |
224 | def after_destroy |
225 | Topic.find(self.topic_id).destroy if self.root? |
226 | rescue ActiveRecord::RecordNotFound |
227 | end |
228 | |
229 | def after_save |
230 | self.topic(:reload) |
231 | self.topic.update_last_post_data |
232 | self.topic.update_post_counter |
233 | self.topic.subject = self.subject if self.root? |
234 | self.topic.save |
235 | |
236 | if @new_attachment |
237 | self.attach_file(@new_attachment.original_filename, @new_attachment.read) |
238 | end |
239 | end |
240 | |
241 | # OTHER METHODS |
242 | |
243 | # pseudo attribute for ferret indexer |
244 | def forum_id |
245 | self.topic.forum_id |
246 | end |
247 | |
248 | # Add a reply to the current post, and return the saved reply |
249 | def add_reply(post) |
250 | # Transaction::Simple is not necessary here, because post won't be |
251 | # used anymore |
252 | transaction do |
253 | post.parent = self |
254 | post.topic = self.topic |
255 | post.save |
256 | end |
257 | return post |
258 | end |
259 | |
260 | def attach_file(filename, data) |
261 | a = Attachment.new |
262 | a.filename = filename |
263 | a.data = data |
264 | a.post = self |
265 | a.save |
266 | a |
267 | end |
268 | |
269 | def get_display_name |
270 | self.author.get_display_name or '(unknown)' |
271 | end |
272 | |
273 | # Contructs the References header for a post by combining messageid's of all |
274 | # (direct or indirect) parents of this message |
275 | def get_references |
276 | # This may include hidden posts. |
277 | messageids = connection.select_all <<-EOL |
278 | SELECT parent.messageid as id |
279 | FROM posts parent, posts child |
280 | WHERE child.id = #{self.id} |
281 | AND parent.topic_id = child.topic_id |
282 | AND parent.id <> #{self.id} |
283 | AND child.l BETWEEN parent.l AND parent.r |
284 | ORDER BY parent.l |
285 | EOL |
286 | messageids.inject('') { |refs, msgid| refs + "<#{msgid['id']}> " }.strip |
287 | end |
288 | |
289 | def has_undeleted_children? |
290 | (self.children.find_all("deleted = 0").size > 0) |
291 | end |
292 | |
293 | def hide(recursive=false) |
294 | self.update_attribute('deleted', 1) |
295 | if recursive |
296 | self.children.each { |child| child.hide(:recursive) } |
297 | end |
298 | end |
299 | |
300 | def unhide(recursive=false) |
301 | raise "Error: can't unhide post if topic is hidden" if self.topic.hidden? |
302 | self.update_attribute('deleted', 0) |
303 | if recursive |
304 | self.children.each { |child| child.unhide(:recursive) } |
305 | end |
306 | end |
307 | |
308 | def hidden? |
309 | (self.deleted == 1) |
310 | end |
311 | |
312 | def quoted_text |
313 | Wraptools::quote(self.text) |
314 | end |
315 | |
316 | # Calculates a reply subject in format "Re: <subject>", |
317 | # unless post's own subject already starts with Re: |
318 | def reply_subject |
319 | s = self.subject |
320 | if s =~ /[Rr]e:.*/ |
321 | s |
322 | else |
323 | "Re: #{s.strip}" |
324 | end |
325 | end |
326 | |
327 | # true if this post has no parents (i.e., it is a topic root) |
328 | def root? |
329 | self.parent_id.nil? |
330 | end |
331 | |
332 | # TODO test me |
333 | def too_many_quoted_lines? |
334 | return false if self.text.nil? |
335 | lines = self.text.split(/\n/).size |
336 | unquoted_lines = (self.text.split(/\n/).delete_if {|l| l =~ /^>/ }).size |
337 | quoted_lines = lines - unquoted_lines |
338 | return (lines > 12 and lines / unquoted_lines > 6) |
339 | end |
340 | |
341 | def author |
342 | self.user or self.guest |
343 | end |
344 | |
345 | def author=(a) |
346 | if a.is_a? User # also true for Admin |
347 | self.user = a |
348 | elsif a.is_a? Guest |
349 | self.guest = a |
350 | else |
351 | raise ArgumentError |
352 | end |
353 | end |
354 | |
355 | private |
356 | |
357 | # Create a quazi-unique (random over a wide range of values, actualy) messageid for a post. |
358 | def make_messageid |
359 | Digest::MD5.hexdigest( |
360 | self.text.to_s + |
361 | self.subject.to_s + |
362 | self.user_id.to_s + |
363 | self.guest_name.to_s + |
364 | self.guest_email.to_s + |
365 | self.parent_id.to_s) + '@' + RForum::CONFIG[:hostname] |
366 | end |
367 | |
368 | # Insert the post into the topic's nested set (which means re-calculating r and l indexes |
369 | # of other posts in the same topic |
370 | def insert_into_nested_set |
371 | if self.root? |
372 | self.l = 1 |
373 | self.r = 2 |
374 | else |
375 | the_parent = self.parent |
376 | self.topic_id = the_parent.topic_id |
377 | self.l = the_parent.r |
378 | self.r = the_parent.r + 1 |
379 | |
380 | # shift all elements that must be right of the new element |
381 | self.class.update_all('l = l + 2', "topic_id = #{self.topic_id} AND l >= #{self.r}") |
382 | self.class.update_all('r = r + 2', "topic_id = #{self.topic_id} AND r >= #{self.l}") |
383 | end |
384 | end |
385 | end |