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