rave 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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