rubot-base 0.0.1

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.
@@ -0,0 +1,1182 @@
1
+ # :main:rubot/base.rb
2
+ # :title:Rubot Documentation
3
+ #
4
+ # = Rubot Base Library for Ruby
5
+ # Author:: Konstantin Haase
6
+ # Requires:: Ruby, Hpricot (>= 0.5), eruby, HTML Entities for Ruby (>= 4.0), Rubygems (optional)
7
+ # License:: MIT-License
8
+ #
9
+ # This is a library for creating bots for MediaWiki projects (i.e. Wikipedia).
10
+ # It can be either used directly or through an adapter library (for handling monitoring, caching and so on).
11
+ #
12
+ # == Status Quo
13
+ # This libary is working quite smooth now but it's not as feature-rich as I want it to be.
14
+ #
15
+ # Heavy improvements to be expected, stay in touch. Some more documentation will follow.
16
+ #
17
+ # == Ruby 1.9
18
+ # Rubot now works with Ruby 1.9. However, hpricot for 1.9 seems to be broken somehow (at least the
19
+ # version from the Ubuntu repo). I'll keep an eye on that one.
20
+ #
21
+ # == Installation
22
+ # At the moment, there is no official release, so you have to use the development version.
23
+ # You can download it using bazaar (http://bazaar-vcs.org)
24
+ #
25
+ # bzr branch http://freifunk-halle.de/~konstantin/rubot
26
+ #
27
+ # <b>First offical version will be released, when uploads are fixed. Enjoy!</b>
28
+ #
29
+ # == Known Bugs
30
+ # * Uploads don't work, yet
31
+ #
32
+ # == TODO
33
+ # * Download files
34
+ #
35
+ # == License
36
+ # Copyright (c) 2008 Konstantin Haase
37
+ #
38
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
39
+ # of this software and associated documentation files (the "Software"), to
40
+ # deal in the Software without restriction, including without limitation the
41
+ # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
42
+ # sell copies of the Software, and to permit persons to whom the Software is
43
+ # furnished to do so, subject to the following conditions:
44
+ #
45
+ # The above copyright notice and this permission notice shall be included in
46
+ # all copies or substantial portions of the Software.
47
+ #
48
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
49
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
50
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
51
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
52
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
53
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
54
+ # IN THE SOFTWARE.
55
+
56
+ # Tries to execute the given block and returns true if no exception is raised,
57
+ # returns false otherwise. Takes as optional argument the exception to rescue.
58
+ def try(exception = Exception)
59
+ begin
60
+ yield
61
+ true
62
+ rescue exception
63
+ false
64
+ end
65
+ end
66
+
67
+ # :stopdoc:
68
+ try(LoadError) { require 'rubygems' }
69
+ # :startdoc:
70
+
71
+ require 'rubot/mime.rb'
72
+ require 'net/http'
73
+ require 'net/https'
74
+ require 'uri'
75
+ require 'hpricot'
76
+ require 'erb'
77
+ require 'logger'
78
+ require 'htmlentities'
79
+ require 'yaml'
80
+
81
+ module Rubot
82
+
83
+ # Base for all Rubot errors
84
+ class Error < StandardError; end
85
+
86
+ # Mixin for all post relatedt errors
87
+ module PostError; end
88
+
89
+ # Raised if not logged in but has to do some register user only stuff.
90
+ # (Mainly changing interface language / time format)
91
+ class NotLoggedInError < Error; end
92
+
93
+ # Error raised if login fails
94
+ class LoginError < Error; include PostError; end
95
+
96
+ # Error raised, when some page related errors occur.
97
+ class PageError < Error; end
98
+
99
+ # Raised if writing to a page failed.
100
+ class WritePageError < PageError; include PostError; end
101
+
102
+ # Raised if moving a page failed.
103
+ class MovePageError < PageError; include PostError; end
104
+
105
+ # Error directly from MediaWiki API
106
+ class ApiError < Error; end
107
+
108
+ # Server access
109
+ #
110
+ # Examples:
111
+ # Rubot[:en] # same as Rubot[:en, :default] unless Rubot.default_family was changed
112
+ # Rubot[:de, :wikipedia]
113
+ def Rubot.[](iw, family = Rubot.default_family)
114
+ Server.server[family.to_sym][iw.to_sym] rescue nil
115
+ end
116
+
117
+ # Default family for wikis. See Rubot.default_family=
118
+ def Rubot.default_family
119
+ @@default_family ||= :default
120
+ @@default_family
121
+ end
122
+
123
+ # Sets default family
124
+ # Comes in very handy when handling a lot of wikis.
125
+ # Example:
126
+ #
127
+ # Rubot :en, :host => 'localhost'
128
+ # Rubot :en, :host => 'en.somewiki.tld', :wiki_family => :somewiki
129
+ # Rubot[:en] # should be local wiki
130
+ # Rubot.new(:de, :host => 'localhost').family # should be :default
131
+ # Rubot.default_family = :somewiki
132
+ # Rubot[:en] # should be wiki at somewiki.tld
133
+ # Rubot.new(:de, :host => 'de.somewiki.tld').family # should be :somewiki
134
+ # Rubot.default_family = :default
135
+ # Rubot[:de] # should be local wiki
136
+ #
137
+ # If you want to connect to multiple wikis Rubot.mass_connect should be very useful, too.
138
+ def Rubot.default_family=(family)
139
+ @@default_family = family
140
+ end
141
+
142
+ # Loops through server.
143
+ def Rubot.each_server
144
+ Server.server.each { |f,s| s.each { |iw, server| yield server } }
145
+ end
146
+
147
+ # Hash-like each_key
148
+ def Rubot.each_key
149
+ Server.server.each { |family,server| server.each_key { |interwiki| yield interwiki, family } }
150
+ end
151
+
152
+ # Loops through wiki families.
153
+ def Rubot.each_family
154
+ Server.server.each { |family, server| yield family, server }
155
+ end
156
+
157
+ # Array containing all servers.
158
+ def Rubot.server
159
+ (Server.server.collect { |k,v| v.values }).flatten
160
+ end
161
+
162
+ # Same as Rubot::Server.new
163
+ def Rubot.new(interwiki, options = {})
164
+ Server.new(interwiki, options)
165
+ end
166
+
167
+ # Used for "magic" values like in Rubot.mass_connect
168
+ #
169
+ # Examples:
170
+ #
171
+ # # 'a string'
172
+ # Rubot.value 'a string', :en
173
+ # Rubot.value { :en => 'a string', :fr => 'another string' }, :en
174
+ # Rubot.value lambda { |v| 'a string' if v == :en }, :en
175
+ def Rubot.value(var, *keys)
176
+ case var
177
+ when Proc
178
+ var.call *keys
179
+ when Hash
180
+ var[*keys]
181
+ when Array
182
+ keys.length == 1 and keys[0].is_a?(Integer) ? var[] : var
183
+ else
184
+ var
185
+ end
186
+ end
187
+
188
+ # The server class. It handels all the communication with the website.
189
+ # See Server.new
190
+ class Server
191
+
192
+ @@server ||= {}
193
+ @@default_adapter ||= nil
194
+ @@coder ||= HTMLEntities.new
195
+ @@english_namespaces ||= [ :main, :talk,
196
+ :user, :user_talk,
197
+ :project, :project_talk,
198
+ :image, :image_talk,
199
+ :mediawiki, :mediawiki_talk,
200
+ :template, :template_talk,
201
+ :help, :help_talk,
202
+ :category, :category_talk ]
203
+
204
+ # Returns hash of wikis
205
+ def self.server
206
+ @@server
207
+ end
208
+
209
+ attr_accessor :cookies, :preferences, :adapter; :use_api
210
+ attr_reader :interwiki, :log, :family
211
+
212
+
213
+ # Creats new Wiki object.
214
+ # Takes Interwiki name (for handling multiple Wikis) and an optional hash of options.
215
+ #
216
+ # Note: You can even use Rubot.new
217
+ #
218
+ # Examples:
219
+ #
220
+ # # MediaWiki on http://localhost/index.php
221
+ # wiki = Rubot::Server.new :en, :host => 'localhost'
222
+ #
223
+ # # Wikipedia - en and de
224
+ # wiki_en = Rubot::Server.new :en, :host => 'en.wikipedia.org',
225
+ # :path => '/w/index.php'
226
+ # wiki_de = Rubot::Server.new :de, :host => 'de.wikipedia.org',
227
+ # :path => '/w/index.php'
228
+ #
229
+ # # Wikipedia, smooth way for loading a couple of wikis
230
+ # # You can access those via Rubot[:en] etc.
231
+ # ['en', 'de', 'fr', 'se'].each do |iw|
232
+ # Rubot.new iw, :host => iw+'.wikipedia.org', :path => '/w/index.php'
233
+ # end
234
+ #
235
+ # # With login
236
+ # Rubot::Server.new :xy, :host => 'somehost',
237
+ # :username => 'WikiAdmin',
238
+ # :password => 'qwerty'
239
+ # Options:
240
+ # :host wiki host, has to be set!
241
+ # :port port (default is 80 or 443, depending on ssl)
242
+ # :ssl wheter to use ssl (default is false)
243
+ # :path path to index.php (default is '/index.php')
244
+ # :http_user set http user if wiki is protectet by basic authentication
245
+ # :http_pass set http password if wiki is protectet by basic authentication
246
+ # :proxy_host proxy host, will only be used if set (has to be http proxy!)
247
+ # :proxy_port proxy port, will only be used if set, :proxy_host has to be set
248
+ # in order to use this!
249
+ # :proxy_user proxy user, will only be used if set, :proxy_host has to be set
250
+ # in order to use this!
251
+ # :proxy_pass proxy password, will only be used if set, :proxy_host and
252
+ # :proxy_user has to be set in order to use this!
253
+ # :cookies Hash of preset cookies, I don't think you'll need that one!
254
+ # :username Name of your bot. Has to be an existing wiki user. If not set bot
255
+ # won't try to login and work anonymous. (It's recommended to have a
256
+ # dedicated account for the bot)
257
+ # :password Guess what! Only to be used when :username is set.
258
+ # :identify_as_bot Whether the Bot should identdfy himself as such or should pretent
259
+ # to be a web browser. (default is true)
260
+ # :auto_login Login when calling new (only if :username is set, default is true)
261
+ # :user_agent User agent to be sent to server (default depends on :identify_as_bot)
262
+ # :logger Logger to be used
263
+ # :delay Seconds to wait between to edits. When working on Wikimedia projects
264
+ # this must not be less than 12. It should be at least 1, else MediaWiki
265
+ # flood control or something will prevent edits. Default is 1.
266
+ # :wiki_family groups wikis in families (like pywiki does) for handling multiple
267
+ # "interwiki clouds", if not given, wiki will be added to Rubot.default_family
268
+ # :use_api Whether to use api.php instead of index.php. EXPERIMENTEL, but could increase
269
+ # both speed and stability. (default is false)
270
+ # :gzip Whether or not to use gzip compression for HTTP. (default is true)
271
+ def initialize(interwiki, options = {})
272
+
273
+ # Basic settings
274
+ @interwiki = interwiki.to_sym
275
+ @options = { :host => nil, :port => nil,
276
+ :ssl => false, :path => nil,
277
+ :http_user => nil, :http_pass => nil,
278
+ :proxy_host => nil, :proxy_port => nil,
279
+ :proxy_user => nil, :proxy_pass => nil,
280
+ :logger => nil, :cookies => {},
281
+ :username => nil, :password => nil,
282
+ :identify_as_bot => true, :auto_login => true,
283
+ :user_agent => nil, :delay => 1,
284
+ :wiki_family => Rubot.default_family, :dont_mutter => false,
285
+ :use_api => false, :gzip => true }.merge options.keys_to_sym
286
+ @preferences = { 'wpUserLanguage' => 'en', 'wpDate' => "ISO 8601",
287
+ 'wpOpexternaleditor' => nil, 'wpOppreviewonfirs' => nil }
288
+ @cookies = @options[:cookies].dup
289
+ @logged_in = false
290
+ @checked = false
291
+ @family = @options[:wiki_family].to_sym
292
+ @adapter = @@default_adapter
293
+
294
+ # Default path depends on :use_api
295
+ @options[:path] ||= use_api? ? '/api.php' : '/index.php'
296
+
297
+ # Logging
298
+ if @adapter
299
+ @log = @adapter.log
300
+ else
301
+ @log = @options.delete :logger
302
+ unless @log
303
+ @log = Logger.new(STDOUT)
304
+ @log.level = Logger::INFO
305
+ end
306
+ end
307
+
308
+ # Default port
309
+ unless @options[:port]
310
+ @options[:port] = 80 unless @options[:ssl]
311
+ @options[:port] = 443 if @options[:ssl]
312
+ end
313
+
314
+ # HTTP connection (via proxy if :proxy_host given)
315
+ @log.debug iw.to_s+': Creating HTTP connection'
316
+ @http = Net::HTTP::Proxy(@options[:proxy_host], @options[:proxy_port], @options[:proxy_user],
317
+ @options[:proxy_pass]).new(@options[:host], @options[:port])
318
+ @http.basic_auth(@options[:http_user], @options[:http_pass]) if @options[:http_user]
319
+
320
+ # identify as bot? sets user agent
321
+ unless @options[:user_agent]
322
+ if @options[:identify_as_bot]
323
+ @options[:user_agent] = "Mozilla/5.0 (compatible; Rubot; #{RUBY_PLATFORM}; en)"
324
+ else
325
+ @options[:user_agent] = case rand(4)
326
+ # Firefox on Ubuntu
327
+ when 0
328
+ "Mozilla/5.0 (X11; U; Linux i686; en;"+
329
+ " rv:1.8.1.12)Gecko/20080207 Ubuntu/7"+
330
+ ".10 (gutsy) Firefox/2.0.0.12"
331
+ # Safari on OSX
332
+ when 1
333
+ "Mozilla/5.0 (Macintosh; U; PPC Mac O"+
334
+ "S X; en-en) AppleWebKit/523.12.2 (KH"+
335
+ "TML, like Gecko) Version/3.0.4 Safar"+
336
+ "i/523.12.2"
337
+ # Opera on Windows
338
+ when 2
339
+ "Opera/9.10 (Windows NT 5.0; U; en)"
340
+ # Lynx
341
+ when 3
342
+ "Lynx/2.8.4rel.1 libwww-FM/2.14 SSL-M"+
343
+ "M/1.4.1 OpenSSL/0.9.6c"
344
+ end
345
+ end
346
+ end
347
+
348
+ @log.debug iw.to_s+': Initialized'
349
+
350
+ # login attemp, only if :username given (handled by login)
351
+ login if @options[:auto_login]
352
+
353
+ # For Rubot::Server.server
354
+ @@server[@family] ||= {}
355
+ @@server[@family][@interwiki] = self
356
+
357
+ end
358
+
359
+ # Whether or not api.php is used.
360
+ def use_api?
361
+ @options[:use_api]
362
+ end
363
+
364
+ # Send HTTP request.
365
+ # This is mainly for internal useage.
366
+ # Does all the cookie handling.
367
+ # Takes a hash with options.
368
+ #
369
+ # Options:
370
+ # :method :get, :post or :head (default is :get)
371
+ # :path Path to be requestet (default is set when calling new)
372
+ # :path_values Takes a hash of Data that will be added to the Path
373
+ # :data Data that will be transmitted
374
+ # :headers some additional header (might be overwritten, though)
375
+ # :follow_redirects whether to follow redirects (default is true)
376
+ # :follow_limit max. redirects to follow (default is 10)
377
+ # :body body to be sent (post only, if given :data will be ignored)
378
+ # :retries number of retries if request fails (NOT YET IMPLEMENTED)
379
+ # :gzip whether or not to use gzip compression for this request
380
+
381
+ def request(params = {})
382
+
383
+ # basic settings
384
+ params = { :method => :get, :path => @options[:path],
385
+ :path_values => {}, :data => {},
386
+ :headers => {}, :follow_redirects => true,
387
+ :follow_limit => 10, :body => nil,
388
+ :retries => 2, :gzip => @options[:gzip] }.merge(params.keys_to_sym!)
389
+
390
+ # catching if something went wrong with the path
391
+ params[:path] = @options[:path] unless params[:path]
392
+
393
+ # redirect handling
394
+ raise Error, 'HTTP redirect too deep' if params[:follow_limit] == 0
395
+
396
+ # headers, cookie handling
397
+ params[:headers].merge!({ 'Cookie' => (@cookies.collect do |key,value|
398
+ key.for_url+'='+value.to_s.for_url+';'
399
+ end).join(' '),
400
+ 'User-Agent' => @options[:user_agent] })
401
+ params[:headers]['Accept-Encoding'] = "gzip,deflate" if params[:gzip]
402
+ if params[:method] == :post
403
+ params[:headers]['Content-Type'] ||= 'application/x-www-form-urlencoded'
404
+ else
405
+ params[:path_values].merge! params[:data]
406
+ params[:data] = {}
407
+ end
408
+
409
+ # adding parameters to path
410
+ params[:path] += (case
411
+ when (params[:path_values].empty? or params[:path] =~ /(\?|&)$/)
412
+ ''
413
+ when params[:path] =~ /\?.+$/
414
+ '&'
415
+ else
416
+ '?'
417
+ end) + params[:path_values].to_url_data
418
+ params[:path_values] = {}
419
+
420
+ # handling data
421
+ data = (params[:body] or params[:data].to_url_data) if params[:method] == :post
422
+
423
+ # now do the request
424
+ @log.debug iw.to_s+": Sending request (#{params[:method]}, #{params[:path]})"
425
+ case params[:method]
426
+ when :get
427
+ response = @http.request_get params[:path], params[:headers]
428
+ when :post
429
+ response = @http.request_post params[:path], data, params[:headers]
430
+ when :head
431
+ response = @http.request_head params[:path], params[:headers]
432
+ else
433
+ raise ArgumentError, ':method must be :get, :post or :head'
434
+ end
435
+
436
+ # cookie handling, again
437
+ if response['set-cookie']
438
+ response.get_fields('set-cookie').each do |field|
439
+ field.scan(/([^; ]*)=([^;]*)/).each do |cookie|
440
+ unless ['path', 'expires'].include? cookie[0]
441
+ @log.debug iw.to_s+": Setting cookie #{cookie[0]} = #{cookie[1]}"
442
+ @cookies[cookie[0]] = cookie[1]
443
+ end
444
+ end
445
+ end
446
+ end
447
+
448
+ # Handle redirects / errors / gzip
449
+ case response
450
+ when Net::HTTPSuccess
451
+ body = response.body
452
+ if body and response['Content-Encoding'] == "gzip"
453
+ zstream = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
454
+ body = zstream.inflate body
455
+ zstream.finish
456
+ zstream.close
457
+ end
458
+ {:response => response, :request => params, :body => body }
459
+ when Net::HTTPRedirection
460
+ uri = URI.parse(response['location'])
461
+ path = uri.path
462
+ path += '?'+uri.query if uri.query
463
+ @log.debug iw.to_s+": Following redirect #{params[:path]} to #{path}"
464
+ request params.merge(:path => path, :follow_limit => params[:follow_limit]-1)
465
+ else
466
+ response.error!
467
+ end
468
+
469
+ end
470
+
471
+ # Does a request via api.php
472
+ #
473
+ # First parameter takes path_values, second parameter takes a hash like Server.request.
474
+ # If the third parameter (force) is true no error will be raised, even if use_api?.
475
+ #
476
+ # Note: If use_api? is false, force is true and no path is given Rubot tries to figure
477
+ # out the api path on it's own (simply replacing 'index.php' by 'api.php')
478
+ #
479
+ # The value for :action is by default set to "query"
480
+ #
481
+ # Thus
482
+ # Rubot[:en].api_request :action => 'help'
483
+ # is the same as
484
+ # Rubot[:en].api_request {}, :path_values => { :action => 'help' }
485
+ def api_request(path_values = {}, params = {}, force = false)
486
+ params.keys_to_sym!
487
+ unless use_api?
488
+ raise Error, "Trying to do some MediaWiki API request without using MediaWik API. Try :use_api => true" unless force
489
+ params[:path] ||= @options[:path].sub 'index.php', 'api.php'
490
+ end
491
+ params[:path_values].keys_to_sym!
492
+ params[:path_values] ||= {}
493
+ params[:path_values].merge!(path_values.keys_to_sym.merge({:format=>'yaml'}))
494
+ params[:path_values][:action] ||= 'query'
495
+ result = request(params)
496
+ begin
497
+ data = YAML::load result[:body]
498
+ rescue ArgumentError
499
+ begin
500
+ data = YAML::load result[:body][/---.*\n \*: >/m][0..-8]
501
+ rescue NoMethodError
502
+ if result[:body] =~ /&lt;error code=&quot;(.*)&quot; info=&quot;(.*)&quot;/
503
+ data = {'error' => { 'code' => $1, 'info' => $2 } }
504
+ else
505
+ raise NoMethodError, $!
506
+ end
507
+ end
508
+ end
509
+ data ||= {}
510
+ raise ApiError, "#{data["error"]["code"]}: #{data["error"]["info"] or 'no message'} (#{result[:request][:path]})" if data["error"]
511
+ data
512
+ end
513
+
514
+ # Does a request and returns Hpricot object of body (DRY)
515
+ def hpricot(params = {})
516
+ Hpricot(request(params)[:body])
517
+ end
518
+
519
+ # Loads form data, see request and Rubot::Form.new
520
+ def get_form(handle = :form, params = {})
521
+ @log.debug iw.to_s+': Loading form data'
522
+ page = request(params)
523
+ Form.new page[:body], handle, page[:request][:path]
524
+ end
525
+
526
+ # Submits a form, takes optional request parameters if not given by request_data
527
+ # (i.e. :follow_redirects), for options see request
528
+ def submit_form(form, default_params = {})
529
+ @log.debug iw.to_s+': Sending form data'
530
+ request form.request_data.merge(default_params)
531
+ end
532
+
533
+ # MediaWiki Version. If parameter is true version number will be transformed to be comparable.
534
+ def mw_version(transform = false)
535
+ unless @version
536
+ if use_api?
537
+ @version = api(:meta => 'siteinfo')['query']['general']['generator'][/\d+\.\d+.\d(alpha|beta)?/]
538
+ else
539
+ @version = 'unknown'
540
+ doc = hpricot :path_values => {:title => 'Special:Version' }
541
+ (doc / :script).each { |script| version = $1 if script.inner_html =~ /var wgVersion = "(\w+)";/ }
542
+ if @version == 'unknown'
543
+ (doc / "#bodyContent tr" ).each do |tr|
544
+ try NoMethodError do
545
+ if (tr / :td)[0].at('a').inner_html == 'MediaWiki' and (tr / :td)[1].inner_html =~ /(\d+\.\d+.\d(alpha|beta)?)/
546
+ @version = $1
547
+ break
548
+ end
549
+ end
550
+ end
551
+ end
552
+ if @version == 'unknown'
553
+ (doc / "#bodyContent li" ).each do |li|
554
+ try NoMethodError do
555
+ if li.inner_html.gsub(/<[^>]*>/, '') =~ /MediaWiki: (\d+\.\d+.\d(alpha|beta)?)/
556
+ @version = $1
557
+ break
558
+ end
559
+ end
560
+ end
561
+ end
562
+ end
563
+ end
564
+ transform ? @version.gsub(/\.(\d)\./, '.0\1.').gsub(/\.(\d)\D*$/, '.0\1') : @version
565
+ end
566
+
567
+ # Try to login. False will prevent login from calling check_preferences.
568
+ def login(check = true)
569
+ @logged_in = false
570
+ if @options[:username]
571
+ @log.info iw.to_s+': Trying to login as '+@options[:username]
572
+ if use_api?
573
+ data = api :action => :login, :lgname => @options[:username], :lgpassword => @options[:password]
574
+ if data['login']['result'] != 'Success'
575
+ @log.warn "#{iw}: Login failed: #{data['result']['login']}"
576
+ raise LoginError, "failed to login (#{data['result']['login']})"
577
+ else
578
+ @logged_in == true
579
+ end
580
+ else
581
+ form = get_form "form[@name='userlogin']", :path_values => {:title => 'Special:Userlogin' }
582
+ form['wpName'] = @options[:username]
583
+ form['wpPassword'] = @options[:password]
584
+ form['wpRemember'] = '1'
585
+ form.submit = 'wpLoginattempt'
586
+ page = submit_form form
587
+ doc = Hpricot page[:body]
588
+ if doc.at('.errorbox')
589
+ @log.warn iw.to_s+': Login failed:'+doc.at('.errorbox').to_s.gsub(/<[^>]*>/, '').gsub(/\n|\t/, ' ').squeeze
590
+ raise LoginError, "failed to login"
591
+ else
592
+ @logged_in = true
593
+ check_preferences if check
594
+ end
595
+ end
596
+ end
597
+ @log.info iw.to_s+': Logged in!' if @logged_in
598
+ end
599
+
600
+ # Returns true if logged in, else false.
601
+ def logged_in?
602
+ try { @logged_in = (not [0, nil].include? api(:meta => 'userinfo')['query']['userinfo']['id']) }
603
+ @logged_in
604
+ end
605
+
606
+ # Checks preferences.
607
+ def check_preferences(force = false)
608
+ if use_api?
609
+ @log.debug iw.to_s+': Not checking preferences (using API).'
610
+ @checked = true
611
+ else
612
+ @log.warn iw.to_s+': Not checking preferences, need to login first!' unless logged_in?
613
+ if logged_in? and (!@checked or force)
614
+ @log.debug iw.to_s+': Checking preferences'
615
+ changes = []
616
+ form = get_form "form", :path_values => {:title => 'Special:Preferences' }
617
+ @preferences.each do |key,value|
618
+ if form[key] != value
619
+ form[key] = value
620
+ if value
621
+ changes.push "'#{key}' = '#{value}'"
622
+ else
623
+ changes.push "remove '#{key}'"
624
+ end
625
+ end
626
+ end
627
+ unless changes.empty?
628
+ @log.info iw.to_s+": Changing some preferences (#{changes.join(', ')})"
629
+ form.submit = 'wpSaveprefs'
630
+ submit_form form
631
+ end
632
+ @checked = true
633
+ end
634
+ end
635
+ end
636
+
637
+ # Reads contents of a page
638
+ def read_page(page)
639
+ @log.info iw.to_s+": Reading page '#{page}'"
640
+ if use_api?
641
+ result = api :prop => 'revisions', :titles => page, :rvprop => 'content'
642
+ try(NoMethodError) { return result['query']['pages'][0]['revisions'][0]['*'] }
643
+ "\n"
644
+ else
645
+ @@coder.decode(
646
+ hpricot(:path_values => {:title => page, :action => :edit, :internaledit => true}).at('#wpTextbox1').inner_html )
647
+ end
648
+ end
649
+
650
+ # Writes to a page
651
+ def write_page(page, text, summary, minor = false)
652
+ login unless logged_in?
653
+ if (Time.now - last_edit) < @options[:delay]
654
+ @log.debug iw.to_s+": Delay - editing another page is not yet permitted."
655
+ sleep((@options[:delay] - (Time.now - last_edit)).ceil)
656
+ end
657
+ if use_api?
658
+ raise NotImplementedError, "Writing pages with MediaWiki's build-in API is not yet possible."
659
+ else
660
+ @log.warn iw.to_s+": Writing to page '#{page}' without being logged in!" unless logged_in? or @options[:dont_mutter]
661
+ @log.debug iw.to_s+": Reading page '#{page}'"
662
+ form = get_form '#editform', :path_values => {:title => page, :action => :edit, :internaledit => true}
663
+ @log.info iw.to_s+": Writing page '#{page}'"
664
+ form['wpTextbox1'] = text
665
+ form['wpSummary'] = summary
666
+ form['wpMinoredit'] = "1" if minor
667
+ form.submit = 'wpSave'
668
+ page = submit_form form
669
+ doc = Hpricot page[:body]
670
+ if doc.at('.errorbox')
671
+ @log.error iw.to_s+": Failed to write to page '#{page}':"+
672
+ doc.at('.errorbox').to_s.gsub(/<[^>]*>/, '').gsub(/\n|\t/, ' ').squeeze
673
+ raise WritePageError, "Failed to write to page '#{page}'."
674
+ end
675
+ end
676
+ last_edit = Time.now
677
+ page
678
+ end
679
+
680
+ # Gets last edit time.
681
+ def last_edit
682
+ if @adapter and @adapter.respond_to? :last_edit
683
+ @adapter.last_edit self
684
+ else
685
+ @last_edit ||= Time.at 0
686
+ @last_edit
687
+ end
688
+ end
689
+
690
+ # Sets last edit time.
691
+ def last_edit=(time)
692
+ if @adapter and @adapter.respond_to? :set_last_edit
693
+ @adapter.set_last_edit self, time
694
+ else
695
+ @last_edit = time
696
+ end
697
+ end
698
+
699
+ # Gets page history. Offset is for recursion.
700
+ def page_history(page, offset = false)
701
+ raise NotImplementedError, "Reading page history with MediaWiki's build-in API is not yet possible." if use_api?
702
+ @log.info iw.to_s+": Reading page history for #{page}" unless offset
703
+ history = {}
704
+ if logged_in?
705
+ check_preferences
706
+ path_values = { :title => page, :action => :history }
707
+ path_values[:offset] = offset if offset
708
+ doc = hpricot :path_values => path_values
709
+ (doc / '#pagehistory li').each do |element|
710
+ id = element.at("input[@name='oldid']")[:value].to_i if element.at("input[@name='oldid']")
711
+ history[id] = {:user => element.at('.history-user').at('a').inner_html,
712
+ :oldid => id,
713
+ :comment => element.at('.comment'),
714
+ :time => Time.local(*(ParseDate.parsedate element.inner_html[/20\d\d-\d\d-\d\dT\d\d:\d\d:\d\d/])) }
715
+ end
716
+ (doc / 'a[@href]').each do |a|
717
+ if a.inner_html['next'] and a[:href] =~ /offset=(\d*)&amp;action=history/
718
+ history.merge! page_history(page, $1)
719
+ break
720
+ end
721
+ end
722
+ else
723
+ @log.error iw.to_s+": Will only read history, if logged in (cannot parse time otherwise)"
724
+ raise NotLoggedInError, "cannot read time format, need to log in."
725
+ end
726
+ offset ? history : history.values.sort_by { |a| a[:time] }
727
+ end
728
+
729
+ # Moves a page
730
+ def move(from, to, reason, keep_redirect = true)
731
+ raise NotImplementedError, "Moving pages with MediaWiki's build-in API is not yet possible." if use_api?
732
+ @log.info iw.to_s+": Moving page '#{from}' to '#{to}'"
733
+ login unless logged_in?
734
+ if (Time.now - last_edit) < @options[:delay]
735
+ @log.debug iw.to_s+": Delay - editing another page is not yet permitted."
736
+ sleep((@options[:delay] - (Time.now - last_edit)).ceil)
737
+ end
738
+ form = get_form "#movepage", :path_values => { :title => "Special:Movepage/#{from}"}
739
+ form['wpNewTitle'] = to
740
+ form['wpReason'] = reason
741
+ form.submit = 'wpMove'
742
+ page = submit_form form
743
+ doc = Hpricot page[:body]
744
+ if doc.at('.errorbox')
745
+ @log.error iw.to_s+": Failed to move page '#{from}' to '#{to}':"+
746
+ doc.at('.errorbox').to_s.gsub(/<[^>]*>/, '').gsub(/\n|\t/, ' ').squeeze
747
+ raise MovePageError, "Failed to move page '#{from}' to '#{to}'."
748
+ end
749
+ last_edit = Time.now
750
+ page
751
+ end
752
+
753
+ # Lists recent changes. If called first time, will list the last 500 changes, when called again will only
754
+ # list new changes (up to 500). The options given will be added directly to the path, so you can pass all
755
+ # you can pass to Special:Recentchanges in MediaWiki (from, limit, hideminor, days, etc). Note that limit
756
+ # cannot be more than 500 and days cannot be more than 30. All hide parameters take 0 or 1, from will be
757
+ # set automaticly depending on last request of recent_changes. However, if you overwrite it, it has to be
758
+ # in the format of YYMMDDhhmmss.
759
+ #
760
+ # Returns Hash with Arrays :edits, :deletes, :moves, :uploads, :protections and :user_rights.
761
+ #
762
+ # For more advanced handling of recent changes, use base/utils or Adapter.
763
+ def recent_changes(path_values = {})
764
+ raise NotImplementedError, "Reading recent changes with MediaWiki's build-in API is not yet possible." if use_api?
765
+ @log.info iw.to_s+": Reading recent changes"
766
+ check_preferences
767
+ @rc_from ||= ''
768
+ doc = hpricot :path_values => { :title => 'Special:Recentchanges', :from => @rc_from,
769
+ :limit => 500, :hidebots => 0,
770
+ :hideminor => 0, :hideanons => 0,
771
+ :hideliu => 0, :hidepatrolled => 0,
772
+ :hidemyself => 0, :days => 30 }.merge(path_values.keys_to_sym)
773
+ (doc / :script).each do |script|
774
+ if script.inner_html =~ /var wgUserLanguage = "(\w+)";/ and $1 != 'en'
775
+ @log.error iw.to_s+": Will only read recent changes, if interface language is english (can be solved be simply logging in!)"
776
+ raise NotLoggedInError, "cannot read language '#{$1}', need to log in."
777
+ return nil
778
+ end
779
+ end
780
+ (doc / 'a[@href]').each do |a|
781
+ if a[:href] =~ /from=(\d\d\d\d\d\d\d\d\d\d\d\d\d\d)/
782
+ @rc_from = $1
783
+ break
784
+ end
785
+ end
786
+ edits, deletes, moves, uploads, protections, user_rights = [], [], [], [], [], []
787
+ (doc / '.special li').each do |element|
788
+ case element.at('a').inner_html.downcase
789
+ when "diff", "hist"
790
+ edits.push( { :page => element.at('a')[:title],
791
+ :comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
792
+ :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
793
+ :new => (element.inner_html[0..5] == "(diff)"),
794
+ :minor => (element / '.minor' ).empty?,
795
+ :bot => (element / '.bot' ).empty?,
796
+ :oldid => ($1.to_i if element.at('a')[:href] =~ /oldid=(\d*)$/) })
797
+ when "deletion log"
798
+ deletes.push( { :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
799
+ :comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
800
+ :page => element.at('.comment a').inner_html })
801
+ when "move log"
802
+ moves.push( { :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
803
+ :comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
804
+ :from => (element / '.comment a')[0].inner_html,
805
+ :to => (element / '.comment a')[1].inner_html })
806
+ when "upload log"
807
+ uploads.push( { :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
808
+ :comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
809
+ :file => element.at('.comment a').inner_html })
810
+ when "protection log"
811
+ protections.push( { :user => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
812
+ :comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or ""),
813
+ :page => element.at('.comment a').inner_html,
814
+ :rules => element.at('.comment').inner_html[/\[.*\]\)/][1..-3].split(':').collect_hash do |rule|
815
+ {$1 => $2} if rule =~ /^(.*)=(.*)$/
816
+ end })
817
+ when "user rights log"
818
+ user_rights.push( { :user_1 => ((element / :a).detect { |a| a.inner_html == 'Talk' })[:title][/[^:]*$/],
819
+ :user_2 => ((element.at('.comment a').inner_html[/[^:]*$/] if element.at('.comment a')) or ""),
820
+ :comment => ((element.at('.comment').inner_html[1..-2] if element.at('.comment')) or "") })
821
+ end
822
+ end
823
+ { :edits => edits, :deletes => deletes,
824
+ :moves => moves, :uploads => uploads,
825
+ :protections => protections, :user_rights => user_rights }
826
+ end
827
+
828
+ # Uploads data
829
+ def upload(file_name, summary, data)
830
+ raise NotImplementedError, "Uploading files with MediaWiki's build-in API is not yet possible." if use_api?
831
+ @log.info iw.to_s+": Uploading file '#{file_name}'"
832
+ @log.warn "UPLOADS DON'T WORK RIGHT NOW! But have a try..."
833
+ form = get_form '#upload', :path_values => { :title => 'Special:Upload' }
834
+ form['wpDestFile'] = file_name
835
+ form['wpUploadFile'] = data
836
+ form['wpUploadDescription'] = summary
837
+ form['wpIgnoreWarning'] = 'true'
838
+ form.submit = 'wpUpload'
839
+ form.set_file_name 'wpUploadFile', file_name
840
+ form.set_mime_type 'wpUploadFile', MIME_TYPES[file_name[/[^\.]*$/]]
841
+ submit_form form
842
+ end
843
+
844
+ # Array of pages in a given Category
845
+ def category_pages(category, from = nil)
846
+ raise NotImplementedError, "Reading categories with MediaWiki's build-in API is not yet possible." if use_api?
847
+ @log.info iw.to_s+": Reading page list '#{category}'."
848
+ doc = hpricot :path_values => { :title => "Category:#{category}", :from => from }
849
+ (doc / :script).each do |script|
850
+ if script.inner_html =~ /var wgUserLanguage = "(\w+)";/ and $1 != 'en'
851
+ @log.error iw.to_s+": Will only read recent changes, if interface language is english (can be solved be simply logging in!)"
852
+ raise NotLoggedInError, "cannot read language '#{$1}', need to log in."
853
+ return nil
854
+ end
855
+ end
856
+ from = nil
857
+ from = @@coder.decode($1) if (doc / "a[@href]").detect { |a| a.inner_html['next'] and a[:href] =~ /from=(.*)$/ }
858
+ ((doc / 'li a').collect { |e| e[:title] if e[:title] == e.inner_html } +
859
+ (from ? [URI::decode(from).gsub(/[\+_]/, ' ')]+category_pages(category, from) : [])).compact.uniq.sort
860
+ end
861
+
862
+ # Localized namespaces.
863
+ #
864
+ # Examples:
865
+ # Rubot[:de].local_namespace(:Talk) # Could be something like "Diskussion"
866
+ # Rubot[:en].local_namespace(:Main) # Should be "(Main)"
867
+ def local_namespace(ns = :all, force_reload = false)
868
+ raise NotImplementedError, "Loading namespaces with MediaWiki's build-in API is not yet possible." if use_api?
869
+ if force_reload or not defined?(@ns)
870
+ @log.debug iw.to_s+": Loading localized namespaces."
871
+ elements = (hpricot(:path_values => { :title => 'Special:Search' }) / '#powersearch span')
872
+ elements = (hpricot(:path_values => { :title => 'Special:Search' }) / '#powersearch label') if elements.empty?
873
+ @ns = elements.collect_hash do |element|
874
+ { element.at('input')[:name][2..-1].to_i => @@coder.decode(element.inner_html.gsub(/<[^>]*>/, '')).gsub(/\302|\240/, " ").strip }
875
+ end
876
+ end
877
+ ns == :all ? @ns : @ns[ns_index(ns)].to_s
878
+ end
879
+
880
+ # Returns prefix like it's to be used in links
881
+ #
882
+ # Examples:
883
+ # Rubot[:en].namespace(:main) # => ''
884
+ # Rubot[:en].namespace(:talk) # => 'Talk:'
885
+ # Rubot[:de].namespace(:image) # => 'Bild:'
886
+ def namespace(ns)
887
+ lns(ns) =~ /^\(.*\)$/ ? '' : "#{lns(ns)}:"
888
+ end
889
+
890
+ # Returns namespace number
891
+ def namespace_index(ns)
892
+ case
893
+ when ns.is_a?(Integer)
894
+ ns
895
+ when ns =~ /^ns(\d*)$/
896
+ $1.to_i
897
+ when @@english_namespaces.include?(ns)
898
+ @@english_namespaces.index ns
899
+ when lns.include?(ns)
900
+ lns.index ns
901
+ when @@english_namespaces.include?(ns.to_s.downcase.to_sym)
902
+ @@english_namespaces.index ns.to_s.downcase.to_sym
903
+ else
904
+ (lns.detect { |key,value| value.to_s.downcase == ns.to_s.downcase })[0]
905
+ end
906
+ end
907
+
908
+ # Returns all pages in the given namespace.
909
+ def pages_in_namespace(ns = :main, from = '!')
910
+ raise NotImplementedError, "Listing pages in a namespace with MediaWiki's build-in API is not yet possible." if use_api?
911
+ doc = hpricot :path_values => { :title => "Special:Allpages", :namespace => ns_index(ns), :from => from }
912
+ new_from = nil
913
+ ary = ((doc / "td a").collect do |a|
914
+ if a[:title] == n(ns)+a.inner_html
915
+ new_from = a.inner_html
916
+ @@coder.decode a[:title]
917
+ end
918
+ end).compact.sort
919
+ ary += pages_in_namespace(ns, new_from) if new_from and new_from != from
920
+ ary.uniq.sort
921
+ end
922
+
923
+ # Returns all pages in all namespaces
924
+ def all_pages
925
+ (lns(:all).values.collect { |ns| pages_in_namespace ns }).flatten
926
+ end
927
+
928
+ # Sets log (should be a logger object).
929
+ def log=(logger)
930
+ if @adapter
931
+ @log = @adapter.log
932
+ log.warn("Could not set log for #{interwiki.to_s}, since logging is controlled by adapter.") unless logger == @log
933
+ else
934
+ @log = logger
935
+ end
936
+ end
937
+
938
+ # shortform
939
+ alias iw interwiki
940
+ alias ns_index namespace_index
941
+ alias lns local_namespace
942
+ alias n namespace
943
+ alias pages_in_category category_pages
944
+ alias api api_request
945
+
946
+ end
947
+
948
+
949
+
950
+ # Class for handling html forms, does not realy depend on Rubot stuff, so if you can use the result of
951
+ # request_data (which is a hash to be understood by Server.request), feel free to do so. Make sure
952
+ # to load hpricot, uri and mime/types.
953
+ class Form
954
+
955
+ include Enumerable
956
+
957
+ attr_accessor :method, :path, :force, :multipart, :boundary
958
+ attr_reader :submit
959
+
960
+ # Greps Formular for given source (handle should be css-like or XPath)
961
+ def initialize(source, handle, default_path)
962
+ @doc = Hpricot(source)
963
+ @form = @doc.at(handle)
964
+ @method = (@form[:method] || 'get').downcase.to_sym
965
+ @submit = nil
966
+ @force = false
967
+ @elements = {}
968
+ @@coder ||= HTMLEntities.new
969
+ @path = @@coder.decode((@form[:action] || default_path))
970
+ @multipart = false
971
+ @boundary = '349832898984244898448024464570528145'
972
+ (@form / :input).each do |element|
973
+ ignore = false
974
+ case (element[:type] || 'text').downcase.to_sym
975
+ when :text, :password, :hidden
976
+ @elements[element[:name]] = { :mode => :text, :type => element[:type], :value => element[:value] }
977
+ when :checkbox
978
+ @elements[element[:name]] = { :mode => :select, :type => element[:type],
979
+ :possible => [nil, element[:value]], :value => nil }
980
+ @elements[element[:name]][:value] = element[:value] if element[:checked]
981
+ when :radio
982
+ @elements[element[:name]] = { :possible => [] } unless @elements[element[:name]]
983
+ @elements[element[:name]][:mode] = :select
984
+ @elements[element[:name]][:type] = element[:type]
985
+ @elements[element[:name]][:value] = element[:value] if element[:checked]
986
+ @elements[element[:name]][:possible].push(element[:value])
987
+ when :submit, :image
988
+ @submit = element[:name] unless @submit
989
+ @elements[element[:name]] = { :mode => :submit, :type => element[:type],
990
+ :value => (element[:value] or element[:src]) }
991
+ when :file
992
+ @multipart = true
993
+ @method = :post
994
+ @elements[element[:name]] = { :mode => :file, :type => element[:type],
995
+ :value => '', :mime_type => 'text/plain',
996
+ :file_name => element[:name] }
997
+ when :reset, :button
998
+ ignore = true
999
+ end
1000
+ @elements[element[:name]][:disabled] = (element[:readonly] or element[:disabled]) != nil unless ignore
1001
+ end
1002
+ (@form / :textarea).each do |element|
1003
+ @elements[element[:name]] = { :mode => :text, :type => 'textarea', :value => element.inner_html,
1004
+ :disabled => (element[:readonly] or element[:disabled]) != nil }
1005
+ end
1006
+ (@form / :select).each do |element|
1007
+ @elements[element[:name]] = { :mode => :select, :type => 'select', :value => nil, :possible => [],
1008
+ :disabled => (element[:readonly] or element[:disabled]) != nil }
1009
+ (element / :option).each do |option|
1010
+ @elements[element[:name]][:possible].push((option[:value] or option.inner_html))
1011
+ @elements[element[:name]][:value] = (option[:value] or option.inner_html) if option[:selected]
1012
+ end
1013
+ end
1014
+ end
1015
+
1016
+ # sets submit field to be used
1017
+ def submit=(name)
1018
+ @submit = name if @elements[name] and @elements[name][:mode] == :submit
1019
+ end
1020
+
1021
+ # sets value of field +element+, if force is true even set if it wouldn't be possible
1022
+ def set_value(element, value, force = @force)
1023
+ @elements[element] = { :mode => :text, :type => nil } if !@elements[element] and force
1024
+ @elements[element][:value] = value if force or (!@elements[element][:disabled] and
1025
+ (@elements[element][:mode] == :text or
1026
+ @elements[element][:mode] == :file or
1027
+ (@elements[element][:mode] == :select and
1028
+ @elements[element][:possible].include? value)))
1029
+ end
1030
+
1031
+ # returns mime type of field
1032
+ def get_mime_type(element)
1033
+ @elements[element] ? @elements[element][:mime_type] : nil
1034
+ end
1035
+
1036
+ # sets mime type of field
1037
+ def set_mime_type(element, mime_type)
1038
+ @elements[element][:mime_type] = mime_type if @elements[element] and @elements[element][:mode] == :file
1039
+ end
1040
+
1041
+ # returns file name of field
1042
+ def get_file_name(element)
1043
+ @elements[element] ? @elements[element][:file_name] : nil
1044
+ end
1045
+
1046
+ # sets mime type of field
1047
+ def set_file_name(element, file_name)
1048
+ @elements[element][:file_name] = file_name if @elements[element] and @elements[element][:mode] == :file
1049
+ end
1050
+
1051
+ # returns value of given field
1052
+ def [](element)
1053
+ @elements[element] ? @elements[element][:value] : nil
1054
+ end
1055
+
1056
+ # alias for set_value
1057
+ def []=(element, value)
1058
+ set_value(element, value, @force)
1059
+ end
1060
+
1061
+ # loops through elements and passes name and value of fields
1062
+ def each(&block) # :yield: name, value
1063
+ @elements.each { |key, value| block.call key, value[:value] }
1064
+ end
1065
+
1066
+ # Hash-like
1067
+ def each_value(&block)
1068
+ @elements.each_value { |value| block.call value }
1069
+ end
1070
+
1071
+ # Hash-like
1072
+ def each_key(&block)
1073
+ @elements.each_key { |key| block.call key }
1074
+ end
1075
+
1076
+ # returns true if element is readonly
1077
+ def readonly? element
1078
+ @elements[element][:disabled]
1079
+ end
1080
+
1081
+ # returns false if element is readonly
1082
+ def writable? element
1083
+ not readonly? element
1084
+ end
1085
+
1086
+ # Data for request
1087
+ def request_data
1088
+ if @multipart
1089
+ data = []
1090
+ @elements.each do |name,element|
1091
+ unless element[:mode] == :select and element[:value] == nil
1092
+ if element[:mode] == :file
1093
+ data.push "Content-Disposition: form-data; name=\"#{name.for_url}\"; filename=\"#{element[:file_name]}\"\r\n" +
1094
+ "Content-Transfer-Encoding: binary\r\n" + "Content-Type: #{element[:mime_type]}\r\n" +
1095
+ "\r\n#{element[:value]}\r\n"
1096
+ elsif element[:mode] != :submit or @submit == name
1097
+ data.push "Content-Disposition: form-data; name=\"#{name.for_url}\"\r\n" +
1098
+ "\r\n#{element[:value]}\r\n"
1099
+ end
1100
+ end
1101
+ end
1102
+ data.push "Content-Disposition: form-data; name=\"submit\"\r\n\r\n#{@submit}\r\n"
1103
+ { :body => data.collect {|e| "--#{@boundary}\r\n#{e}"}.join('') + "--#{@boundary}--\r\n",
1104
+ :headers => {"Content-Type" => "multipart/form-data; boundary=" + @boundary},
1105
+ :method => @method,
1106
+ :path => @path }
1107
+ else
1108
+ data = {}
1109
+ @elements.each do |name,element|
1110
+ data[name] = element[:value] if (element[:mode] == :submit and @submit == name) or
1111
+ (element[:mode] == :select and element[:value] != nil) or
1112
+ element[:mode] == :text
1113
+ end
1114
+ data[:submit] = @submit
1115
+ { :method => @method, :path => @path, :data => data }
1116
+ end
1117
+ end
1118
+
1119
+ end
1120
+
1121
+ end
1122
+
1123
+
1124
+
1125
+ # Monkey patching
1126
+ class Object
1127
+ def for_url
1128
+ ERB::Util.url_encode(self.to_s)
1129
+ end
1130
+ end
1131
+
1132
+
1133
+ # Monkey patching
1134
+ class Array
1135
+
1136
+ def collect_hash
1137
+ result = {}
1138
+ self.each do |value|
1139
+ subhash = yield value
1140
+ result.merge!(subhash) if subhash.is_a? Hash
1141
+ end
1142
+ result
1143
+ end
1144
+
1145
+ def for_all?(&block)
1146
+ result = true
1147
+ self.each { |value| result = (result and block.call value) }
1148
+ result
1149
+ end
1150
+
1151
+ def exists?(&block)
1152
+ result = false
1153
+ self.each { |value| result = (result or block.call value) }
1154
+ result
1155
+ end
1156
+
1157
+ end
1158
+
1159
+
1160
+ # Monkey patching
1161
+ class Hash
1162
+ def to_url_data
1163
+ (self.collect { |key,value| key.to_s.for_url+'='+value.to_s.for_url }).join('&')
1164
+ end
1165
+
1166
+ def keys_to_sym
1167
+ self.to_a.collect_hash { |a| { a[0].to_sym => a[1] } }
1168
+ end
1169
+
1170
+ def keys_to_sym!
1171
+ self.each_key { |key| self[key.to_sym] = self.delete key }
1172
+ end
1173
+
1174
+ def collect_hash
1175
+ result = {}
1176
+ self.each do |key,value|
1177
+ subhash = yield key, value
1178
+ result.merge!(subhash) if subhash.is_a? Hash
1179
+ end
1180
+ result
1181
+ end
1182
+ end