mediawiki-gateway 0.6.2 → 1.0.0.rc1
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.
- checksums.yaml +4 -4
- data/COPYING +22 -0
- data/ChangeLog +16 -0
- data/README.md +80 -21
- data/Rakefile +28 -34
- data/bin/mediawiki-gateway +203 -0
- data/lib/media_wiki.rb +4 -9
- data/lib/media_wiki/exception.rb +11 -8
- data/lib/media_wiki/fake_wiki.rb +636 -0
- data/lib/media_wiki/gateway.rb +105 -940
- data/lib/media_wiki/gateway/files.rb +173 -0
- data/lib/media_wiki/gateway/pages.rb +400 -0
- data/lib/media_wiki/gateway/query.rb +98 -0
- data/lib/media_wiki/gateway/site.rb +101 -0
- data/lib/media_wiki/gateway/users.rb +182 -0
- data/lib/media_wiki/utils.rb +47 -13
- data/lib/media_wiki/version.rb +27 -0
- data/lib/mediawiki-gateway.rb +1 -0
- data/spec/{import-test-data.xml → data/import.xml} +0 -0
- data/spec/media_wiki/gateway/files_spec.rb +34 -0
- data/spec/media_wiki/gateway/pages_spec.rb +390 -0
- data/spec/media_wiki/gateway/query_spec.rb +84 -0
- data/spec/media_wiki/gateway/site_spec.rb +122 -0
- data/spec/media_wiki/gateway/users_spec.rb +171 -0
- data/spec/media_wiki/gateway_spec.rb +129 -0
- data/spec/{live_gateway_spec.rb → media_wiki/live_gateway_spec.rb} +31 -35
- data/spec/{utils_spec.rb → media_wiki/utils_spec.rb} +41 -39
- data/spec/spec_helper.rb +17 -16
- metadata +77 -135
- data/.ruby-version +0 -1
- data/.rvmrc +0 -34
- data/Gemfile +0 -19
- data/Gemfile.lock +0 -77
- data/LICENSE +0 -21
- data/config/hosts.yml +0 -17
- data/lib/media_wiki/config.rb +0 -69
- data/mediawiki-gateway.gemspec +0 -113
- data/samples/README +0 -18
- data/samples/create_page.rb +0 -13
- data/samples/delete_batch.rb +0 -14
- data/samples/download_batch.rb +0 -15
- data/samples/email_user.rb +0 -14
- data/samples/export_xml.rb +0 -14
- data/samples/get_page.rb +0 -11
- data/samples/import_xml.rb +0 -14
- data/samples/run_fake_media_wiki.rb +0 -8
- data/samples/search_content.rb +0 -12
- data/samples/semantic_query.rb +0 -17
- data/samples/upload_commons.rb +0 -45
- data/samples/upload_file.rb +0 -13
- data/spec/fake_media_wiki/api_pages.rb +0 -135
- data/spec/fake_media_wiki/app.rb +0 -360
- data/spec/fake_media_wiki/query_handling.rb +0 -136
- data/spec/gateway_spec.rb +0 -888
@@ -0,0 +1,98 @@
|
|
1
|
+
module MediaWiki
|
2
|
+
|
3
|
+
class Gateway
|
4
|
+
|
5
|
+
module Query
|
6
|
+
|
7
|
+
# Get a list of pages with matching content in given namespaces
|
8
|
+
#
|
9
|
+
# [key] Search key
|
10
|
+
# [namespaces] Array of namespace names to search (defaults to main only)
|
11
|
+
# [limit] Maximum number of hits to ask for (defaults to 500; note that Wikimedia Foundation wikis allow only 50 for normal users)
|
12
|
+
# [max_results] Maximum total number of results to return
|
13
|
+
# [options] Hash of additional options
|
14
|
+
#
|
15
|
+
# Returns array of page titles (empty if no matches)
|
16
|
+
def search(key, namespaces = nil, limit = @options[:limit], max_results = @options[:max_results], options = {})
|
17
|
+
titles, offset, form_data = [], 0, options.merge(
|
18
|
+
'action' => 'query',
|
19
|
+
'list' => 'search',
|
20
|
+
'srwhat' => 'text',
|
21
|
+
'srsearch' => key,
|
22
|
+
'srlimit' => limit
|
23
|
+
)
|
24
|
+
|
25
|
+
if namespaces
|
26
|
+
form_data['srnamespace'] = Array(namespaces).map! { |ns|
|
27
|
+
namespaces_by_prefix[ns]
|
28
|
+
}.join('|')
|
29
|
+
end
|
30
|
+
|
31
|
+
begin
|
32
|
+
form_data['sroffset'] = offset if offset
|
33
|
+
form_data['srlimit'] = [limit, max_results - offset.to_i].min
|
34
|
+
|
35
|
+
res, offset = make_api_request(form_data, '//query-continue/search/@sroffset')
|
36
|
+
|
37
|
+
titles += REXML::XPath.match(res, '//p').map { |x| x.attributes['title'] }
|
38
|
+
end while offset && offset.to_i < max_results.to_i
|
39
|
+
|
40
|
+
titles
|
41
|
+
end
|
42
|
+
|
43
|
+
# Execute Semantic Mediawiki query
|
44
|
+
#
|
45
|
+
# [query] Semantic Mediawiki query
|
46
|
+
# [params] Array of additional parameters or options, eg. mainlabel=Foo or ?Place (optional)
|
47
|
+
# [options] Hash of additional options
|
48
|
+
#
|
49
|
+
# Returns result as an HTML string
|
50
|
+
def semantic_query(query, params = [], options = {})
|
51
|
+
unless smw_version = extensions['Semantic MediaWiki']
|
52
|
+
raise MediaWiki::Exception, 'Semantic MediaWiki extension not installed.'
|
53
|
+
end
|
54
|
+
|
55
|
+
if smw_version.to_f >= 1.7
|
56
|
+
send_request(options.merge(
|
57
|
+
'action' => 'ask',
|
58
|
+
'query' => "#{query}|#{params.join('|')}"
|
59
|
+
))
|
60
|
+
else
|
61
|
+
send_request(options.merge(
|
62
|
+
'action' => 'parse',
|
63
|
+
'prop' => 'text',
|
64
|
+
'text' => "{{#ask:#{query}|#{params.push('format=list').join('|')}}}"
|
65
|
+
)).elements['parse/text'].text
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Make a custom query
|
70
|
+
#
|
71
|
+
# [options] query options
|
72
|
+
#
|
73
|
+
# Returns the REXML::Element object as result
|
74
|
+
#
|
75
|
+
# Example:
|
76
|
+
# def creation_time(pagename)
|
77
|
+
# res = bot.custom_query(:prop => :revisions,
|
78
|
+
# :titles => pagename,
|
79
|
+
# :rvprop => :timestamp,
|
80
|
+
# :rvdir => :newer,
|
81
|
+
# :rvlimit => 1)
|
82
|
+
# timestr = res.get_elements('*/*/*/rev')[0].attribute('timestamp').to_s
|
83
|
+
# time.parse(timestr)
|
84
|
+
# end
|
85
|
+
#
|
86
|
+
def custom_query(options)
|
87
|
+
form_data = {}
|
88
|
+
options.each { |k, v| form_data[k.to_s] = v.to_s }
|
89
|
+
send_request(form_data.merge('action' => 'query')).elements['query']
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
93
|
+
|
94
|
+
include Query
|
95
|
+
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
module MediaWiki
|
2
|
+
|
3
|
+
class Gateway
|
4
|
+
|
5
|
+
module Site
|
6
|
+
|
7
|
+
# Imports a MediaWiki XML dump
|
8
|
+
#
|
9
|
+
# [xml] String or array of page names to fetch
|
10
|
+
# [options] Hash of additional options
|
11
|
+
#
|
12
|
+
# Returns XML array <api><import><page/><page/>...
|
13
|
+
# <page revisions="1"> (or more) means successfully imported
|
14
|
+
# <page revisions="0"> means duplicate, not imported
|
15
|
+
def import(xmlfile, options = {})
|
16
|
+
send_request(options.merge(
|
17
|
+
'action' => 'import',
|
18
|
+
'xml' => File.new(xmlfile),
|
19
|
+
'token' => get_token('import', 'Main Page'), # NB: dummy page name
|
20
|
+
'format' => 'xml'
|
21
|
+
))
|
22
|
+
end
|
23
|
+
|
24
|
+
# Exports a page or set of pages
|
25
|
+
#
|
26
|
+
# [page_titles] String or array of page titles to fetch
|
27
|
+
# [options] Hash of additional options
|
28
|
+
#
|
29
|
+
# Returns MediaWiki XML dump
|
30
|
+
def export(page_titles, options = {})
|
31
|
+
send_request(options.merge(
|
32
|
+
'action' => 'query',
|
33
|
+
'titles' => Array(page_titles).join('|'),
|
34
|
+
'export' => nil,
|
35
|
+
'exportnowrap' => nil
|
36
|
+
))
|
37
|
+
end
|
38
|
+
|
39
|
+
# Get the wiki's siteinfo as a hash. See http://www.mediawiki.org/wiki/API:Siteinfo.
|
40
|
+
#
|
41
|
+
# [options] Hash of additional options
|
42
|
+
def siteinfo(options = {})
|
43
|
+
res = send_request(options.merge(
|
44
|
+
'action' => 'query',
|
45
|
+
'meta' => 'siteinfo'
|
46
|
+
))
|
47
|
+
|
48
|
+
REXML::XPath.first(res, '//query/general')
|
49
|
+
.attributes.each_with_object({}) { |(k, v), h| h[k] = v }
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get the wiki's MediaWiki version.
|
53
|
+
#
|
54
|
+
# [options] Hash of additional options passed to #siteinfo
|
55
|
+
def version(options = {})
|
56
|
+
siteinfo(options).fetch('generator', '').split.last
|
57
|
+
end
|
58
|
+
|
59
|
+
# Get a list of all known namespaces
|
60
|
+
#
|
61
|
+
# [options] Hash of additional options
|
62
|
+
#
|
63
|
+
# Returns array of namespaces (name => id)
|
64
|
+
def namespaces_by_prefix(options = {})
|
65
|
+
res = send_request(options.merge(
|
66
|
+
'action' => 'query',
|
67
|
+
'meta' => 'siteinfo',
|
68
|
+
'siprop' => 'namespaces'
|
69
|
+
))
|
70
|
+
|
71
|
+
REXML::XPath.match(res, '//ns').each_with_object({}) { |namespace, namespaces|
|
72
|
+
prefix = namespace.attributes['canonical'] || ''
|
73
|
+
namespaces[prefix] = namespace.attributes['id'].to_i
|
74
|
+
}
|
75
|
+
end
|
76
|
+
|
77
|
+
# Get a list of all installed (and registered) extensions
|
78
|
+
#
|
79
|
+
# [options] Hash of additional options
|
80
|
+
#
|
81
|
+
# Returns array of extensions (name => version)
|
82
|
+
def extensions(options = {})
|
83
|
+
res = send_request(options.merge(
|
84
|
+
'action' => 'query',
|
85
|
+
'meta' => 'siteinfo',
|
86
|
+
'siprop' => 'extensions'
|
87
|
+
))
|
88
|
+
|
89
|
+
REXML::XPath.match(res, '//ext').each_with_object({}) { |extension, extensions|
|
90
|
+
name = extension.attributes['name'] || ''
|
91
|
+
extensions[name] = extension.attributes['version']
|
92
|
+
}
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
|
97
|
+
include Site
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
@@ -0,0 +1,182 @@
|
|
1
|
+
module MediaWiki
|
2
|
+
|
3
|
+
class Gateway
|
4
|
+
|
5
|
+
module Users
|
6
|
+
|
7
|
+
# Login to MediaWiki
|
8
|
+
#
|
9
|
+
# [username] Username
|
10
|
+
# [password] Password
|
11
|
+
# [domain] Domain for authentication plugin logins (eg. LDAP), optional -- defaults to 'local' if not given
|
12
|
+
# [options] Hash of additional options
|
13
|
+
#
|
14
|
+
# Throws MediaWiki::Unauthorized if login fails
|
15
|
+
def login(username, password, domain = 'local', options = {})
|
16
|
+
send_request(options.merge(
|
17
|
+
'action' => 'login',
|
18
|
+
'lgname' => username,
|
19
|
+
'lgpassword' => password,
|
20
|
+
'lgdomain' => domain
|
21
|
+
))
|
22
|
+
|
23
|
+
@password = password
|
24
|
+
@username = username
|
25
|
+
end
|
26
|
+
|
27
|
+
# Get a list of users
|
28
|
+
#
|
29
|
+
# [options] Optional hash of options, eg. { 'augroup' => 'sysop' }. See http://www.mediawiki.org/wiki/API:Allusers
|
30
|
+
#
|
31
|
+
# Returns array of user names (empty if no matches)
|
32
|
+
def users(options = {})
|
33
|
+
iterate_query('allusers', '//u', 'name', 'aufrom', options.merge(
|
34
|
+
'aulimit' => @options[:limit]
|
35
|
+
))
|
36
|
+
end
|
37
|
+
|
38
|
+
# Get user contributions
|
39
|
+
#
|
40
|
+
# user: The user name
|
41
|
+
# count: Maximum number of contributions to retreive, or nil for all
|
42
|
+
# [options] Optional hash of options, eg. { 'ucnamespace' => 4 }. See http://www.mediawiki.org/wiki/API:Usercontribs
|
43
|
+
#
|
44
|
+
# Returns array of hashes containing the "item" attributes defined here: http://www.mediawiki.org/wiki/API:Usercontribs
|
45
|
+
def contributions(user, count = nil, options = {})
|
46
|
+
result = []
|
47
|
+
|
48
|
+
iterate_query('usercontribs', '//item', nil, 'uccontinue', options.merge(
|
49
|
+
'ucuser' => user,
|
50
|
+
'uclimit' => @options[:limit]
|
51
|
+
)) { |element|
|
52
|
+
result << hash = {}
|
53
|
+
element.attributes.each { |key, value| hash[key] = value }
|
54
|
+
}
|
55
|
+
|
56
|
+
count ? result.take(count) : result
|
57
|
+
end
|
58
|
+
|
59
|
+
# Sends e-mail to a user
|
60
|
+
#
|
61
|
+
# [user] Username to send mail to (name only: eg. 'Bob', not 'User:Bob')
|
62
|
+
# [subject] Subject of message
|
63
|
+
# [content] Content of message
|
64
|
+
# [options] Hash of additional options
|
65
|
+
#
|
66
|
+
# Will raise a 'noemail' APIError if the target user does not have a confirmed email address, see http://www.mediawiki.org/wiki/API:E-mail for details.
|
67
|
+
def email_user(user, subject, text, options = {})
|
68
|
+
res = send_request(options.merge(
|
69
|
+
'action' => 'emailuser',
|
70
|
+
'target' => user,
|
71
|
+
'subject' => subject,
|
72
|
+
'text' => text,
|
73
|
+
'token' => get_token('email', "User:#{user}")
|
74
|
+
))
|
75
|
+
|
76
|
+
res.elements['emailuser'].attributes['result'] == 'Success'
|
77
|
+
end
|
78
|
+
|
79
|
+
# Create a new account
|
80
|
+
#
|
81
|
+
# [options] is +Hash+ passed as query arguments. See https://www.mediawiki.org/wiki/API:Account_creation#Parameters for more information.
|
82
|
+
def create_account(options)
|
83
|
+
send_request(options.merge('action' => 'createaccount'))
|
84
|
+
end
|
85
|
+
|
86
|
+
# Sets options for currenlty logged in user
|
87
|
+
#
|
88
|
+
# [changes] a +Hash+ that will be transformed into an equal sign and pipe-separated key value parameter
|
89
|
+
# [optionname] a +String+ indicating which option to change (optional)
|
90
|
+
# [optionvalue] the new value for optionname - allows pipe characters (optional)
|
91
|
+
# [reset] a +Boolean+ indicating if all preferences should be reset to site defaults (optional)
|
92
|
+
# [options] Hash of additional options
|
93
|
+
def options(changes = {}, optionname = nil, optionvalue = nil, reset = false, options = {})
|
94
|
+
form_data = options.merge(
|
95
|
+
'action' => 'options',
|
96
|
+
'token' => get_options_token
|
97
|
+
)
|
98
|
+
|
99
|
+
if changes && !changes.empty?
|
100
|
+
form_data['change'] = changes.map { |key, value| "#{key}=#{value}" }.join('|')
|
101
|
+
end
|
102
|
+
|
103
|
+
if optionname && !optionname.empty?
|
104
|
+
form_data[optionname] = optionvalue
|
105
|
+
end
|
106
|
+
|
107
|
+
if reset
|
108
|
+
form_data['reset'] = true
|
109
|
+
end
|
110
|
+
|
111
|
+
send_request(form_data)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Set groups for a user
|
115
|
+
#
|
116
|
+
# [user] Username of user to modify
|
117
|
+
# [groups_to_add] Groups to add user to, as an array or a string if a single group (optional)
|
118
|
+
# [groups_to_remove] Groups to remove user from, as an array or a string if a single group (optional)
|
119
|
+
# [options] Hash of additional options
|
120
|
+
def set_groups(user, groups_to_add = [], groups_to_remove = [], comment = '', options = {})
|
121
|
+
token = get_userrights_token(user)
|
122
|
+
userrights(user, token, groups_to_add, groups_to_remove, comment, options)
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
# User rights management (aka group assignment)
|
128
|
+
def get_userrights_token(user)
|
129
|
+
res = send_request(
|
130
|
+
'action' => 'query',
|
131
|
+
'list' => 'users',
|
132
|
+
'ustoken' => 'userrights',
|
133
|
+
'ususers' => user
|
134
|
+
)
|
135
|
+
|
136
|
+
token = res.elements['query/users/user'].attributes['userrightstoken']
|
137
|
+
|
138
|
+
@log.debug("RESPONSE: #{res.to_s}")
|
139
|
+
|
140
|
+
unless token
|
141
|
+
if res.elements['query/users/user'].attributes['missing']
|
142
|
+
raise APIError.new('invaliduser', "User '#{user}' was not found (get_userrights_token)")
|
143
|
+
else
|
144
|
+
raise Unauthorized.new("User '#{@username}' is not permitted to perform this operation: get_userrights_token")
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
token
|
149
|
+
end
|
150
|
+
|
151
|
+
def get_options_token
|
152
|
+
send_request('action' => 'tokens', 'type' => 'options')
|
153
|
+
.elements['tokens'].attributes['optionstoken']
|
154
|
+
end
|
155
|
+
|
156
|
+
def userrights(user, token, groups_to_add, groups_to_remove, reason, options = {})
|
157
|
+
# groups_to_add and groups_to_remove can be a string or an array. Turn them into MediaWiki's pipe-delimited list format.
|
158
|
+
if groups_to_add.is_a?(Array)
|
159
|
+
groups_to_add = groups_to_add.join('|')
|
160
|
+
end
|
161
|
+
|
162
|
+
if groups_to_remove.is_a?(Array)
|
163
|
+
groups_to_remove = groups_to_remove.join('|')
|
164
|
+
end
|
165
|
+
|
166
|
+
send_request(options.merge(
|
167
|
+
'action' => 'userrights',
|
168
|
+
'user' => user,
|
169
|
+
'token' => token,
|
170
|
+
'add' => groups_to_add,
|
171
|
+
'remove' => groups_to_remove,
|
172
|
+
'reason' => reason
|
173
|
+
))
|
174
|
+
end
|
175
|
+
|
176
|
+
end
|
177
|
+
|
178
|
+
include Users
|
179
|
+
|
180
|
+
end
|
181
|
+
|
182
|
+
end
|
data/lib/media_wiki/utils.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
module MediaWiki
|
2
|
-
|
2
|
+
|
3
|
+
module Utils
|
3
4
|
|
4
5
|
# Extract base name. If there are no subpages, return page name.
|
5
6
|
#
|
@@ -7,7 +8,7 @@ module MediaWiki
|
|
7
8
|
# get_base_name("Namespace:Foo/Bar/Baz") -> "Namespace:Foo"
|
8
9
|
# get_base_name("Namespace:Foo") -> "Namespace:Foo"
|
9
10
|
#
|
10
|
-
# [title] Page name string in Wiki format
|
11
|
+
# [title] Page name string in Wiki format
|
11
12
|
def get_base_name(title)
|
12
13
|
title.split('/').first if title
|
13
14
|
end
|
@@ -18,10 +19,9 @@ module MediaWiki
|
|
18
19
|
# get_path_to_subpage("Namespace:Foo/Bar/Baz") -> "Namespace:Foo/Bar"
|
19
20
|
# get_path_to_subpage("Namespace:Foo") -> nil
|
20
21
|
#
|
21
|
-
# [title] Page name string in Wiki format
|
22
|
+
# [title] Page name string in Wiki format
|
22
23
|
def get_path_to_subpage(title)
|
23
|
-
|
24
|
-
title.split(/\/([^\/]*)$/).first
|
24
|
+
title.split(/\/([^\/]*)$/).first if title && title.include?('/')
|
25
25
|
end
|
26
26
|
|
27
27
|
# Extract subpage name. If there is no hierarchy above, return page name.
|
@@ -30,7 +30,7 @@ module MediaWiki
|
|
30
30
|
# get_subpage("Namespace:Foo/Bar/Baz") -> "Baz"
|
31
31
|
# get_subpage("Namespace:Foo") -> "Namespace:Foo"
|
32
32
|
#
|
33
|
-
# [title] Page name string in Wiki format
|
33
|
+
# [title] Page name string in Wiki format
|
34
34
|
def get_subpage(title)
|
35
35
|
title.split('/').last if title
|
36
36
|
end
|
@@ -42,24 +42,58 @@ module MediaWiki
|
|
42
42
|
def uri_to_wiki(uri)
|
43
43
|
upcase_first_char(CGI.unescape(uri).tr('_', ' ').tr('#<>[]|{}', '')) if uri
|
44
44
|
end
|
45
|
-
|
45
|
+
|
46
46
|
# Convert a Wiki page name ("Getting there & away") to URI-safe format ("Getting_there_%26_away"),
|
47
47
|
# taking care not to mangle slashes or colons
|
48
48
|
# [wiki] Page name string in Wiki format
|
49
49
|
def wiki_to_uri(wiki)
|
50
|
-
wiki.to_s.split('/').map {|chunk|
|
50
|
+
wiki.to_s.split('/').map { |chunk|
|
51
|
+
CGI.escape(CGI.unescape(chunk).tr(' ', '_'))
|
52
|
+
}.join('/').gsub('%3A', ':') if wiki
|
51
53
|
end
|
52
54
|
|
53
55
|
# Return current version of MediaWiki::Gateway
|
54
56
|
def version
|
55
57
|
MediaWiki::VERSION
|
56
58
|
end
|
57
|
-
|
59
|
+
|
58
60
|
private
|
59
|
-
|
60
|
-
|
61
|
-
|
61
|
+
|
62
|
+
NO_UNICODE_SUPPORT = begin
|
63
|
+
require 'unicode'
|
64
|
+
rescue LoadError
|
65
|
+
begin
|
66
|
+
require 'active_support/core_ext/string/multibyte'
|
67
|
+
rescue LoadError
|
68
|
+
warn 'mediawiki-gateway: For better Unicode support,' <<
|
69
|
+
" install the `unicode' or `activesupport' gem."
|
70
|
+
|
71
|
+
def upcase_first_char(str)
|
72
|
+
first, rest = str.slice(0, 1), str.slice(1..-1)
|
73
|
+
[first.upcase, rest].join
|
74
|
+
end
|
75
|
+
|
76
|
+
'No Unicode support'
|
77
|
+
else
|
78
|
+
def upcase_first_char(str)
|
79
|
+
mb_str = str.mb_chars
|
80
|
+
first, rest = mb_str.slice(0, 1), mb_str.slice(1..-1)
|
81
|
+
[ActiveSupport::Multibyte::Chars.new(first).upcase.to_s, rest].join
|
82
|
+
end
|
83
|
+
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
else
|
87
|
+
def upcase_first_char(str)
|
88
|
+
first, rest = str.slice(0, 1), str.slice(1..-1)
|
89
|
+
[Unicode.upcase(first), rest].join
|
90
|
+
end
|
91
|
+
|
92
|
+
nil
|
62
93
|
end
|
94
|
+
|
63
95
|
end
|
64
|
-
|
96
|
+
|
97
|
+
extend Utils
|
98
|
+
|
65
99
|
end
|