rave 0.1.1 → 0.1.2

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.
@@ -1,69 +1,269 @@
1
- # Represents a Wavelet, owned by a Wave
2
- module Rave
3
- module Models
4
- class Wavelet
5
- attr_reader :creator, :creation_time, :data_documents, :last_modified_time,
6
- :participants, :root_blip_id, :title, :version, :wave_id, :id
7
- attr_accessor :context #Context needs to be able to set this
8
-
9
- ROOT_ID_SUFFIX = "conv+root" #The suffix for the root wavelet in a wave]
10
- ROOT_ID_REGEXP = /conv\+root$/
11
-
12
- # Options include:
13
- # - :creator
14
- # - :creation_time
15
- # - :data_documents
16
- # - :last_modifed_time
17
- # - :participants
18
- # - :root_blip_id
19
- # - :title
20
- # - :version
21
- # - :wave_id
22
- # - :context
23
- # - :id
24
- def initialize(options = {})
25
- @creator = options[:creator]
26
- @creation_time = options[:creation_time] || Time.now
27
- @data_documents = options[:data_documents] || {}
28
- @last_modified_time = options[:last_modified_time] || Time.now
29
- @participants = Set.new(options[:participants])
30
- @root_blip_id = options[:root_blip_id]
31
- @title = options[:title]
32
- @version = options[:version] || 0
33
- @wave_id = options[:wave_id]
34
- @context = options[:context]
35
- @id = options[:id]
36
- end
37
-
38
- #Creates a blip for this wavelet
39
- def create_blip
40
- #TODO
41
- blip = Blip.new(:wave_id => @wave_id, :wavelet_id => @id)
42
- @context.operations << Operation.new(:type => Operation::WAVELET_APPEND_BLIP, :wave_id => @wave_id, :wavelet_id => @id, :prop => blip)
43
- @context.add_blip(blip)
44
- blip
45
- end
46
-
47
- #Adds a participant to the wavelet
48
- def add_participant(participant_id)
49
- #TODO
50
- end
51
-
52
- #Removes this robot from the wavelet
53
- def remove_robot
54
- #TODO
55
- end
56
-
57
- #Sets the data document for the wavelet
58
- def set_data_document(name, data)
59
- #TODO
60
- end
61
-
62
- #Set the title
63
- def title=(title)
64
- @title = title
65
- end
66
-
67
- end
68
- end
69
- end
1
+ module Rave
2
+ module Models
3
+ # Represents a Wavelet, owned by a Wave
4
+ class Wavelet < Component
5
+ include Rave::Mixins::TimeUtils
6
+ include Rave::Mixins::Logger
7
+
8
+ # Creator of the wavelet if it was generated via an operation.
9
+ GENERATED_CREATOR = "rusty@a.gwave.com" # :nodoc:
10
+
11
+ # Current version number of the wavelet [Integer]
12
+ attr_reader :version
13
+
14
+ # ID of the creator [String]
15
+ def creator_id # :nodoc:
16
+ @creator_id.dup
17
+ end
18
+
19
+ # Time the wavelet was created [Time]
20
+ def creation_time # :nodoc:
21
+ @creation_time.dup
22
+ end
23
+
24
+ # Documents contained within the wavelet [Array of Document]
25
+ def data_documents # :nodoc:
26
+ @data_documents.dup
27
+ end
28
+
29
+ # The last time the wavelet was modified [Time]
30
+ def last_modified_time # :nodoc:
31
+ @last_modified_time.dup
32
+ end
33
+
34
+ # ID for the root blip [String]
35
+ def root_blip_id # :nodoc:
36
+ @root_blip_id.dup
37
+ end
38
+
39
+ # Wavelet title [String]
40
+ def title # :nodoc:
41
+ @title.dup
42
+ end
43
+
44
+ # ID of the wave that the wavelet is a part of [String]
45
+ def wave_id # :nodoc:
46
+ @wave_id.dup
47
+ end
48
+
49
+ # IDs of all those who are currently members of the wavelet [Array of String]
50
+ def participant_ids # :nodoc:
51
+ @participant_ids.map { |id| id.dup }
52
+ end
53
+
54
+ JAVA_CLASS = 'com.google.wave.api.impl.WaveletData'
55
+ ROOT_ID_SUFFIX = "conv+root" #The suffix for the root wavelet in a wave]
56
+ ROOT_ID_REGEXP = /#{Regexp.escape(ROOT_ID_SUFFIX)}$/
57
+
58
+ #
59
+ # Options include:
60
+ # - :creator
61
+ # - :creation_time
62
+ # - :data_documents
63
+ # - :last_modifed_time
64
+ # - :participants
65
+ # - :root_blip_id
66
+ # - :title
67
+ # - :version
68
+ # - :wave_id
69
+ # - :context
70
+ # - :id
71
+ def initialize(options = {}) # :nodoc:
72
+ @participant_ids = options[:participants] || []
73
+
74
+ if options[:id].nil? and options[:context]
75
+ # Generate the wavelet from scratch.
76
+ super(:id => "#{GENERATED_PREFIX}_wavelet_#{unique_id}_#{ROOT_ID_SUFFIX}", :context => options[:context])
77
+
78
+ # Create a wave to live in.
79
+ wave = Wave.new(:wavelet_ids => [@id], :context => @context)
80
+ @wave_id = wave.id
81
+ @context.add_wave(wave)
82
+
83
+ # Ensure the newly created wavelet has a root blip.
84
+ blip = Blip.new(:wave_id => wave.id, :wavelet_id => @id,
85
+ :creator => @context.robot.id, :contributors => [@context.robot.id])
86
+ @context.add_blip(blip)
87
+ @root_blip_id = blip.id
88
+
89
+ @participant_ids.each do |id|
90
+ @context.add_user(:id => id) unless @context.users[id]
91
+ end
92
+
93
+ @creator_id = GENERATED_CREATOR
94
+ @context.add_user(:id => @creator_id) unless @context.users[@creator_id]
95
+ else
96
+ super(options)
97
+ @root_blip_id = options[:root_blip_id]
98
+ @creator_id = options[:creator] || User::NOBODY_ID
99
+ @wave_id = options[:wave_id]
100
+ end
101
+
102
+ @creation_time = time_from_json(options[:creation_time]) || Time.now
103
+ @data_documents = options[:data_documents] || {}
104
+ @last_modified_time = time_from_json(options[:last_modified_time]) || Time.now
105
+ @title = options[:title] || ''
106
+ @version = options[:version] || 0
107
+ end
108
+
109
+ # Users that are currently have access the wavelet [Array of User]
110
+ def participants # :nodoc:
111
+ @participant_ids.map { |p| @context.users[p] }
112
+ end
113
+
114
+ # Users that originally created the wavelet [User]
115
+ def creator # :nodoc:
116
+ @context.users[@creator_id]
117
+ end
118
+
119
+ # Is this the root wavelet for its wave? [Boolean]
120
+ def root? # :nodoc:
121
+ not (id =~ ROOT_ID_REGEXP).nil?
122
+ end
123
+
124
+ #Creates a blip for this wavelet
125
+ # Returns: Gererated blip [Blip]
126
+ def create_blip
127
+ parent = final_blip
128
+ blip = Blip.new(:wave_id => @wave_id, :parent_blip_id => parent.id,
129
+ :wavelet_id => @id, :context => @context)
130
+ parent.add_child_blip(blip)
131
+
132
+ @context.add_operation(:type => Operation::WAVELET_APPEND_BLIP, :wave_id => @wave_id, :wavelet_id => @id, :property => blip)
133
+ blip
134
+ end
135
+
136
+ # Find the last blip in the main thread [Blip]
137
+ def final_blip # :nodoc:
138
+ blip = @context.blips[@root_blip_id]
139
+ if blip
140
+ while blip
141
+ # Find the first blip that is defined, if at all.
142
+ child_blip = blip.child_blips.find { |b| not b.nil? }
143
+ break unless child_blip
144
+ blip = child_blip
145
+ end
146
+ end
147
+ blip
148
+ end
149
+
150
+ # Adds a participant (human or robot) to the wavelet
151
+ # +user+:: User to add, as ID or object [String or User]
152
+ # Returns: The user that was added [User or nil]
153
+ def add_participant(user) # :nodoc:
154
+ id = user.to_s.downcase
155
+ if @participant_ids.include?(id)
156
+ logger.warning("Attempted to add a participant who was already in the wavelet(#{@id}): #{id}")
157
+ return nil
158
+ end
159
+
160
+ # Allow string names to be used as participant.
161
+ user = if @context.users[id]
162
+ @context.users[id]
163
+ else
164
+ @context.add_user(:id => id)
165
+ end
166
+
167
+ @context.add_operation(:type => Operation::WAVELET_ADD_PARTICIPANT,
168
+ :wave_id => @wave_id, :wavelet_id => @id, :property => user)
169
+ @participant_ids << id
170
+
171
+ user
172
+ end
173
+
174
+ # Removes a participant (robot only) from the wavelet.
175
+ # +user+:: User to remove, as ID or object [String or User]
176
+ # Returns: The user that was removed [User or nil]
177
+ def remove_participant(user) # :nodoc:
178
+ id = user.to_s.downcase
179
+ unless @participant_ids.include?(id)
180
+ logger.warning("Attempted to remove a participant who was not in the wavelet(#{@id}): #{id}")
181
+ return nil
182
+ end
183
+
184
+ # Allow string names to be used as participant.
185
+ user = @context.users[id]
186
+
187
+ unless user.robot?
188
+ logger.warning("Attempted to remove a non-robot from wavelet(#{@id}): #{id}")
189
+ return nil
190
+ end
191
+
192
+ if user == @context.robot
193
+ return remove_robot
194
+ end
195
+
196
+ @context.add_operation(:type => Operation::WAVELET_REMOVE_PARTICIPANT,
197
+ :wave_id => @wave_id, :wavelet_id => @id, :property => user)
198
+ @participant_ids.delete id
199
+
200
+ user
201
+ end
202
+
203
+ # Removes the local robot from the wavelet.
204
+ # Returns: The local robot [Robot]
205
+ def remove_robot
206
+ robot = @context.robot
207
+ @context.add_operation(:type => Operation::WAVELET_REMOVE_SELF,
208
+ :wave_id => @wave_id, :wavelet_id => @id)
209
+ @participant_ids.delete robot.id
210
+
211
+ robot
212
+ end
213
+
214
+ #Sets the data document for the wavelet
215
+ #
216
+ # NOT IMPLEMENTED
217
+ def set_data_document(name, data)
218
+ raise NotImplementedError
219
+ end
220
+
221
+ #Set the title
222
+ #
223
+ def title=(title) # :nodoc: Documented by title() as accessor.
224
+ title = title.to_s
225
+ @context.add_operation(:type => Operation::WAVELET_SET_TITLE,
226
+ :wave_id => @wave_id, :wavelet_id => @id, :property => title)
227
+ @title = title
228
+ end
229
+
230
+ # First blip in the wavelet [Blip]
231
+ def root_blip # :nodoc:
232
+ @context.blips[@root_blip_id]
233
+ end
234
+
235
+ # Wave that the wavelet is contained within.
236
+ def wave# :nodoc:
237
+ @context.waves[@wave_id]
238
+ end
239
+
240
+ # *INTERNAL*
241
+ # Convert to json for sending in an operation.
242
+ def to_json # :nodoc:
243
+ {
244
+ 'waveletId' => @id,
245
+ 'javaClass' => JAVA_CLASS,
246
+ 'waveId' => @wave_id,
247
+ 'rootBlipId' => @root_blip_id,
248
+ 'participants' => { "javaClass" => "java.util.ArrayList", "list" => @participant_ids }
249
+ }.to_json
250
+ end
251
+
252
+ # Convert to string.
253
+ def to_s
254
+ text = @title.length > 24 ? "#{@title[0..20]}..." : @title
255
+ "#{super}:#{participants.join(',')}:#{text}"
256
+ end
257
+
258
+ def print_structure(indent = 0) # :nodoc:
259
+ str = "#{' ' * indent}#{to_s}\n"
260
+
261
+ if root_blip
262
+ str << root_blip.print_structure(indent + 1)
263
+ end
264
+
265
+ str
266
+ end
267
+ end
268
+ end
269
+ end
data/lib/ops/blip_ops.rb CHANGED
@@ -1,134 +1,233 @@
1
- #Reopen the blip class and add operation-related methods
2
- module Rave
3
- module Models
4
- class Blip
5
-
6
- #Clear the content
7
- def clear
8
- @context.operations << Operation.new(
9
- :type => Operation::DOCUMENT_DELETE,
10
- :blip_id => @id,
11
- :wavelet_id => @wavelet_id,
12
- :wave_id => @wave_id,
13
- :index => 0,
14
- :property => 0..(@content ? @content.length : 0)
15
- )
16
- @content = ''
17
- end
18
-
19
- #Insert text at an index
20
- def insert_text(text, index)
21
- @context.operations << Operation.new(
22
- :type => Operation::DOCUMENT_INSERT,
23
- :blip_id => @id,
24
- :wavelet_id => @wavelet_id,
25
- :wave_id => @wave_id,
26
- :index => index,
27
- :property => text
28
- )
29
- @content = @content ? @content[0, index] + text + @content[index, @content.length - index] : text
30
- end
31
-
32
- #Set the content text of the blip
33
- def set_text(text)
34
- clear
35
- insert_text(text, 0)
36
- end
37
-
38
- #Deletes the text in a given range and replaces it with the given text
39
- def set_text_in_range(range, text)
40
- #Note: I'm doing this in the opposite order from the python API, because
41
- # otherwise, if you are setting text at the end of the content, the cursor
42
- # gets moved to the start of the range...
43
- insert_text(text, range.first)
44
- delete_range(range.first+text.length..range.last+text.length)
45
- end
46
-
47
- #Appends text to the end of the content
48
- def append_text(text)
49
- @context.operations << Operation.new(
50
- :type => Operation::DOCUMENT_APPEND,
51
- :blip_id => @id,
52
- :wavelet_id => @wavelet_id,
53
- :wave_id => @wave_id,
54
- :property => text
55
- )
56
- @content = @content + text
57
- end
58
-
59
- #Deletes text in the given range
60
- def delete_range(range)
61
- @context.operations << Operation.new(
62
- :type => Operation::DOCUMENT_DELETE,
63
- :blip_id => @id,
64
- :wavelet_id => @wavelet_id,
65
- :wave_id => @wave_id,
66
- :index => range.first,
67
- :property => range
68
- )
69
- @content = @content[0..range.first-1] + @content[range.last+1..@content.length-1]
70
- end
71
-
72
- #Annotates the entire content
73
- def annotate_document(name, value)
74
- #TODO
75
- raise "This hasn't been implemented yet"
76
- end
77
-
78
- #Deletes the annotation with the given name
79
- def delete_annotation_by_name(name)
80
- #TODO
81
- raise "This hasn't been implemented yet"
82
- end
83
-
84
- #Deletes the annotations with the given key in the given range
85
- def delete_annotation_in_range(range, name)
86
- #TODO
87
- raise "This hasn't been implemented yet"
88
- end
89
-
90
- #Appends an inline blip to this blip
91
- def append_inline_blip
92
- #TODO
93
- raise "This hasn't been implemented yet"
94
- end
95
-
96
- #Deletes an inline blip from this blip
97
- def delete_inline_blip(blip_id)
98
- #TODO
99
- raise "This hasn't been implemented yet"
100
- end
101
-
102
- #Inserts an inline blip at the given position
103
- def insert_inline_blip(position)
104
- #TODO
105
- raise "This hasn't been implemented yet"
106
- end
107
-
108
- #Deletes an element at the given position
109
- def delete_element(position)
110
- #TODO
111
- raise "This hasn't been implemented yet"
112
- end
113
-
114
- #Inserts the given element in the given position
115
- def insert_element(position, element)
116
- #TODO
117
- raise "This hasn't been implemented yet"
118
- end
119
-
120
- #Replaces the element at the given position with the given element
121
- def replace_element(position, element)
122
- #TODO
123
- raise "This hasn't been implemented yet"
124
- end
125
-
126
- #Appends an element
127
- def append_element(element)
128
- #TODO
129
- raise "This hasn't been implemented yet"
130
- end
131
-
132
- end
133
- end
134
- end
1
+ module Rave
2
+ module Models
3
+ class Blip
4
+ # Reopen the blip class and add operation-related methods
5
+
6
+ VALID_FORMATS = [:plain, :html, :textile] # :nodoc: For set_text/append_text
7
+
8
+ # Clear the content.
9
+ def clear
10
+ return if content.empty? # No point telling the server to clear an empty blip.
11
+ delete_range(0..(@content.length))
12
+ end
13
+
14
+ # Insert text at an index.
15
+ def insert_text(index, text)
16
+ add_operation(:type => Operation::DOCUMENT_INSERT, :index => index, :property => text)
17
+ @content.insert(index, text)
18
+ # TODO: Shift annotations.
19
+
20
+ text
21
+ end
22
+
23
+ # Set the content text of the blip.
24
+ #
25
+ # === Options
26
+ # :+format+ - Format of the text, which can be any one of:
27
+ # * :+html+ - Text marked up with HTML.
28
+ # * :+plain+ - Plain text (default).
29
+ # * :+textile+ - Text marked up with textile.
30
+ #
31
+ # Returns: An empty string [String]
32
+ def set_text(text, options = {})
33
+ clear
34
+ append_text(text, options)
35
+ end
36
+
37
+ # Deletes the text in a given range and replaces it with the given text.
38
+ # Returns: The text altered [String]
39
+ def set_text_in_range(range, text)
40
+ raise ArgumentError.new("Requires a Range, not a #{range.class.name}") unless range.kind_of? Range
41
+
42
+ #Note: I'm doing this in the opposite order from the python API, because
43
+ # otherwise, if you are setting text at the end of the content, the cursor
44
+ # gets moved to the start of the range...
45
+ unless text.empty?
46
+ begin # Failures in this method should give us a range error.
47
+ insert_text(range.min, text)
48
+ rescue IndexError => e
49
+ raise RangeError.new(e.message)
50
+ end
51
+ end
52
+ delete_range(range.min+text.length..range.max+text.length)
53
+ # TODO: Shift annotations.
54
+
55
+ text
56
+ end
57
+
58
+ # Appends text to the end of the blip's current content.
59
+ #
60
+ # === Options
61
+ # :+format+ - Format of the text, which can be any one of:
62
+ # * :+html+ - Text marked up with HTML.
63
+ # * :+plain+ - Plain text (default).
64
+ # * :+textile+ - Text marked up with textile.
65
+ #
66
+ # Returns: The new content string [String]
67
+ def append_text(text, options = {})
68
+ format = options[:format] || :plain
69
+ raise BadOptionError.new(:format, VALID_FORMATS, format) unless VALID_FORMATS.include? format
70
+
71
+ plain_text = text
72
+
73
+ if format == :textile
74
+ text = RedCloth.new(text).to_html
75
+ format = :html # Can now just treat it as HTML.
76
+ end
77
+
78
+ if format == :html
79
+ type = Operation::DOCUMENT_APPEND_MARKUP
80
+ plain_text = strip_html_tags(text)
81
+ else
82
+ type = Operation::DOCUMENT_APPEND
83
+ end
84
+
85
+ add_operation(:type => type, :property => text)
86
+ # TODO: Add annotations for the tags we removed?
87
+ @content += plain_text # Plain text added to text field.
88
+
89
+ @content.dup
90
+ end
91
+
92
+ # Deletes text in the given range.
93
+ # Returns: An empty string [String]
94
+ def delete_range(range)
95
+ raise ArgumentError.new("Requires a Range, not a #{range.class.name}") unless range.kind_of? Range
96
+
97
+ add_operation(:type => Operation::DOCUMENT_DELETE, :index => range.min, :property => range)
98
+
99
+ @content[range] = ''
100
+ # TODO: Shift and/or delete annotations.
101
+
102
+ ''
103
+ end
104
+
105
+ # Annotates the entire content.
106
+ #
107
+ # NOT IMPLEMENTED
108
+ def annotate_document(name, value)
109
+ raise NotImplementedError
110
+ end
111
+
112
+ # Deletes the annotation with the given name.
113
+ #
114
+ # NOT IMPLEMENTED
115
+ def delete_annotation_by_name(name)
116
+ raise NotImplementedError
117
+ end
118
+
119
+ # Deletes the annotations with the given key in the given range.
120
+ #
121
+ # NOT IMPLEMENTED
122
+ def delete_annotation_in_range(range, name)
123
+ raise NotImplementedError
124
+ end
125
+
126
+ # Appends an inline blip to this blip.
127
+ # Returns: Blip created by operation [Blip]
128
+ def append_inline_blip
129
+ # TODO: What happens if there already is an element at end of content?
130
+ blip = Blip.new(:wave_id => @wave_id, :wavelet_id => @wavelet_id)
131
+ @context.add_blip(blip)
132
+ element = Element::InlineBlip.new('blipId' => blip.id)
133
+ element.context = @context
134
+ @elements[@content.length] = element
135
+ add_operation(:type => Operation::DOCUMENT_INLINE_BLIP_APPEND, :property => blip)
136
+
137
+ blip
138
+ end
139
+
140
+ # Deletes an inline blip from this blip.
141
+ # +value+:: Inline blip to delete [Blip]
142
+ #
143
+ # Returns: Blip ID of the deleted blip [String]
144
+ def delete_inline_blip(blip) # :nodoc:
145
+ element = @elements.values.find { |e| e.kind_of?(Element::InlineBlip) and e.blip == blip }
146
+ raise "Blip '#{blip.id}' is not an inline blip of blip '#{id}'" if element.nil?
147
+ #element.blip.destroy_me # TODO: How to deal with children?
148
+ @elements.delete_if { |pos, el| el == element }
149
+ add_operation(:type => Operation::DOCUMENT_INLINE_BLIP_DELETE, :property => blip.id)
150
+
151
+ blip.id
152
+ end
153
+
154
+ # Inserts an inline blip at the given position.
155
+ # Returns: Blip element created by operation [Blip]
156
+ def insert_inline_blip(position)
157
+ # TODO: Complain if element does exist at that position.
158
+ blip = Blip.new(:wave_id => @wave_id, :wavelet_id => @wavelet_id)
159
+ @context.add_blip(blip)
160
+ element = Element::InlineBlip.new('blipId' => blip.id)
161
+ element.context = @context
162
+ @elements[@content.length] = element
163
+ add_operation(:type => Operation::DOCUMENT_INLINE_BLIP_INSERT, :index => position, :property => blip)
164
+
165
+ blip
166
+ end
167
+
168
+ # Deletes an element at the given position.
169
+ def delete_element(position)
170
+ element = @elements[position]
171
+ case element
172
+ when Element::InlineBlip
173
+ return delete_inline_blip(element.blip)
174
+ when Element
175
+ @elements[position] = nil
176
+ add_operation(:type => Operation::DOCUMENT_ELEMENT_DELETE, :index => position)
177
+ else
178
+ raise "No element to delete at position #{position}"
179
+ end
180
+
181
+ self
182
+ end
183
+
184
+ # Inserts the given element in the given position.
185
+ def insert_element(position, element)
186
+ # TODO: Complain if element does exist at that position.
187
+ @elements[position] = element
188
+ add_operation(:type => Operation::DOCUMENT_ELEMENT_INSERT, :index => position, :property => element)
189
+
190
+ element
191
+ end
192
+
193
+ # Replaces the element at the given position with the given element.
194
+ def replace_element(position, element)
195
+ # TODO: Complain if element does not exist at that position.
196
+ @elements[position] = element
197
+ add_operation(:type => Operation::DOCUMENT_ELEMENT_REPLACE, :index => position, :property => element)
198
+
199
+ element
200
+ end
201
+
202
+ # Appends an element
203
+ def append_element(element)
204
+ # TODO: What happens if there already is an element at end of content?
205
+ @elements[@content.length] = element
206
+ add_operation(:type => Operation::DOCUMENT_ELEMENT_APPEND, :property => element)
207
+
208
+ element
209
+ end
210
+
211
+ protected
212
+ def add_operation(options) # :nodoc:
213
+ @context.add_operation(options.merge(:blip_id => @id, :wavelet_id => @wavelet_id, :wave_id => @wave_id))
214
+ end
215
+
216
+ # Strips all HTML tags from a string, returning what it would look like unformatted.
217
+ def strip_html_tags(text) # :nodoc:
218
+ # Replace existing newlines/tabs with spaces, since they don't affect layout.
219
+ str = text.gsub(/[\n\t]/, ' ')
220
+ # Replace all <br /> with a newline.
221
+ str.gsub!(/<br\s*\/>\s*/, "\n")
222
+ # Put newline where are </h?>, </p> </div>, unless at the end.
223
+ str.gsub!(/<\/(?:h\d|p|div)>\s*(?!$)/, "\n")
224
+ # Remove all tags.
225
+ str.gsub!(/<\/?[^<]*>/, '')
226
+ # Remove spaces at each end.
227
+ str.gsub!(/^ +| +$/, '')
228
+ # Compress all adjacent spaces into a single space.
229
+ str.gsub(/ {2,}/, ' ')
230
+ end
231
+ end
232
+ end
233
+ end