mediawiki-gateway 0.6.2 → 1.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|