activesp 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/Rakefile +34 -0
- data/VERSION +1 -0
- data/lib/activesp.rb +26 -0
- data/lib/activesp/base.rb +53 -0
- data/lib/activesp/caching.rb +31 -0
- data/lib/activesp/connection.rb +89 -0
- data/lib/activesp/content_type.rb +103 -0
- data/lib/activesp/field.rb +147 -0
- data/lib/activesp/folder.rb +40 -0
- data/lib/activesp/ghost_field.rb +31 -0
- data/lib/activesp/group.rb +68 -0
- data/lib/activesp/item.rb +109 -0
- data/lib/activesp/list.rb +302 -0
- data/lib/activesp/permission_set.rb +29 -0
- data/lib/activesp/persistent_caching.rb +52 -0
- data/lib/activesp/role.rb +75 -0
- data/lib/activesp/root.rb +64 -0
- data/lib/activesp/site.rb +215 -0
- data/lib/activesp/url.rb +119 -0
- data/lib/activesp/user.rb +59 -0
- data/lib/activesp/util.rb +168 -0
- metadata +95 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
module ActiveSP
|
2
|
+
|
3
|
+
class PermissionSet
|
4
|
+
|
5
|
+
include Util
|
6
|
+
|
7
|
+
attr_reader :scope
|
8
|
+
|
9
|
+
def initialize(scope)
|
10
|
+
@scope = scope
|
11
|
+
end
|
12
|
+
|
13
|
+
def permissions
|
14
|
+
@scope.send(:permissions)
|
15
|
+
end
|
16
|
+
|
17
|
+
def key
|
18
|
+
encode_key("P", [@scope.key])
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
"#<ActiveSP::PermissionSet scope=#{@scope}>"
|
23
|
+
end
|
24
|
+
|
25
|
+
alias inspect to_s
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module ActiveSP
|
2
|
+
|
3
|
+
module PersistentCaching
|
4
|
+
|
5
|
+
def persistent(&blk)
|
6
|
+
class << self ; self ; end.instance_eval do
|
7
|
+
alias_method :old_new, :new
|
8
|
+
define_method(:new) do |*a|
|
9
|
+
cache_scope, indices = *blk.call(a)
|
10
|
+
if cache_scope.respond_to?(:persistent_cache)
|
11
|
+
cache_scope.persistent_cache.lookup(indices) { old_new(*a) }
|
12
|
+
else
|
13
|
+
old_new(*a)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
class PersistentCache
|
22
|
+
|
23
|
+
def initialize
|
24
|
+
@cache = {}
|
25
|
+
end
|
26
|
+
|
27
|
+
def lookup(indices)
|
28
|
+
if o = @cache[indices]
|
29
|
+
# puts " Cache hit for #{indices.inspect}"
|
30
|
+
else
|
31
|
+
o = @cache[indices] ||= yield
|
32
|
+
# puts " Cache miss for #{indices.inspect}"
|
33
|
+
end
|
34
|
+
o
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
module PersistentCachingConfig
|
40
|
+
|
41
|
+
def configure_persistent_cache(&blk)
|
42
|
+
@last_persistent_cache_object = PersistentCache.new
|
43
|
+
class << self ; self ; end.send(:define_method, :persistent_cache) do
|
44
|
+
cache = blk.call(@last_persistent_cache_object)
|
45
|
+
@last_persistent_cache_object = PersistentCache.new unless cache == @last_persistent_cache_object
|
46
|
+
cache
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module ActiveSP
|
2
|
+
|
3
|
+
class Role < Base
|
4
|
+
|
5
|
+
extend Caching
|
6
|
+
extend PersistentCaching
|
7
|
+
include Util
|
8
|
+
include InSite
|
9
|
+
|
10
|
+
attr_reader :name
|
11
|
+
|
12
|
+
persistent { |site, name, *a| [site.connection, [:role, name]] }
|
13
|
+
def initialize(site, name)
|
14
|
+
@site, @name = site, name
|
15
|
+
end
|
16
|
+
|
17
|
+
def key
|
18
|
+
encode_key("R", [@name])
|
19
|
+
end
|
20
|
+
|
21
|
+
def users
|
22
|
+
call("UserGroup", "get_user_collection_from_role", "roleName" => @name).xpath("//spdir:User", NS).map do |row|
|
23
|
+
attributes = clean_attributes(row.attributes)
|
24
|
+
User.new(@site, attributes["LoginName"])
|
25
|
+
end
|
26
|
+
end
|
27
|
+
cache :users, :dup => true
|
28
|
+
|
29
|
+
def groups
|
30
|
+
call("UserGroup", "get_group_collection_from_role", "roleName" => @name).xpath("//spdir:Group", NS).map do |row|
|
31
|
+
attributes = clean_attributes(row.attributes)
|
32
|
+
Group.new(@site, attributes["Name"])
|
33
|
+
end
|
34
|
+
end
|
35
|
+
cache :groups, :dup => true
|
36
|
+
|
37
|
+
def to_s
|
38
|
+
"#<ActiveSP::Role name=#{@name}>"
|
39
|
+
end
|
40
|
+
|
41
|
+
alias inspect to_s
|
42
|
+
|
43
|
+
def is_role?
|
44
|
+
true
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def data
|
50
|
+
call("UserGroup", "get_role_info", "roleName" => @name).xpath("//spdir:Role", NS).first
|
51
|
+
end
|
52
|
+
cache :data
|
53
|
+
|
54
|
+
def attributes_before_type_cast
|
55
|
+
clean_attributes(data.attributes)
|
56
|
+
end
|
57
|
+
cache :attributes_before_type_cast
|
58
|
+
|
59
|
+
def original_attributes
|
60
|
+
type_cast_attributes(@site, nil, internal_attribute_types, attributes_before_type_cast)
|
61
|
+
end
|
62
|
+
cache :original_attributes
|
63
|
+
|
64
|
+
def internal_attribute_types
|
65
|
+
@@internal_attribute_types ||= {
|
66
|
+
"Description" => GhostField.new("Description", "Text", false, true),
|
67
|
+
"ID" => GhostField.new("ID", "Text", false, true),
|
68
|
+
"Name" => GhostField.new("Name", "Text", false, true),
|
69
|
+
"Type" => GhostField.new("Type", "Integer", false, true)
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module ActiveSP
|
2
|
+
|
3
|
+
NS = {
|
4
|
+
"sp" => "http://schemas.microsoft.com/sharepoint/soap/",
|
5
|
+
"z" => "#RowsetSchema",
|
6
|
+
"spdir" => "http://schemas.microsoft.com/sharepoint/soap/directory/"
|
7
|
+
}
|
8
|
+
|
9
|
+
module Root
|
10
|
+
|
11
|
+
extend Caching
|
12
|
+
|
13
|
+
def root
|
14
|
+
Site.new(self, @root_url)
|
15
|
+
end
|
16
|
+
cache :root
|
17
|
+
|
18
|
+
def users
|
19
|
+
root.send(:call, "UserGroup", "get_user_collection_from_site").xpath("//spdir:User", NS).map do |row|
|
20
|
+
attributes = clean_attributes(row.attributes)
|
21
|
+
User.new(root, attributes["LoginName"])
|
22
|
+
end
|
23
|
+
end
|
24
|
+
cache :users, :dup => true
|
25
|
+
|
26
|
+
def groups
|
27
|
+
root.send(:call, "UserGroup", "get_group_collection_from_site").xpath("//spdir:Group", NS).map do |row|
|
28
|
+
attributes = clean_attributes(row.attributes)
|
29
|
+
Group.new(root, attributes["Name"])
|
30
|
+
end
|
31
|
+
end
|
32
|
+
cache :groups, :dup => true
|
33
|
+
|
34
|
+
def roles
|
35
|
+
root.send(:call, "UserGroup", "get_role_collection_from_web").xpath("//spdir:Role", NS).map do |row|
|
36
|
+
attributes = clean_attributes(row.attributes)
|
37
|
+
Role.new(root, attributes["Name"])
|
38
|
+
end
|
39
|
+
end
|
40
|
+
cache :roles, :dup => true
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
class Connection
|
45
|
+
|
46
|
+
include Root
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
module InSite
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def call(*a, &b)
|
55
|
+
@site.send(:call, *a, &b)
|
56
|
+
end
|
57
|
+
|
58
|
+
def fetch(url)
|
59
|
+
@site.send(:fetch, url)
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
module ActiveSP
|
2
|
+
|
3
|
+
class Site < Base
|
4
|
+
|
5
|
+
extend Caching
|
6
|
+
extend PersistentCaching
|
7
|
+
include Util
|
8
|
+
|
9
|
+
attr_reader :url, :connection
|
10
|
+
|
11
|
+
persistent { |connection, url, *a| [connection, [:site, url]] }
|
12
|
+
def initialize(connection, url, depth = 0)
|
13
|
+
@connection, @url, @depth = connection, url, depth
|
14
|
+
@services = {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def relative_url(url = @url)
|
18
|
+
url[@connection.root_url.rindex("/") + 1..-1]
|
19
|
+
end
|
20
|
+
|
21
|
+
def supersite
|
22
|
+
if @depth > 0
|
23
|
+
Site.new(@connection, File.dirname(@url), @depth - 1)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
cache :supersite
|
27
|
+
|
28
|
+
def rootsite
|
29
|
+
@depth > 0 ? supersite.rootsite : self
|
30
|
+
end
|
31
|
+
cache :rootsite
|
32
|
+
|
33
|
+
def is_root_site?
|
34
|
+
@depth == 0
|
35
|
+
end
|
36
|
+
|
37
|
+
def key
|
38
|
+
encode_key("S", [@url[@connection.root_url.length + 1..-1], @depth])
|
39
|
+
end
|
40
|
+
|
41
|
+
def sites
|
42
|
+
result = call("Webs", "get_web_collection")
|
43
|
+
result.xpath("//sp:Web", NS).map { |web| Site.new(connection, web["Url"].to_s, @depth + 1) }
|
44
|
+
end
|
45
|
+
cache :sites, :dup => true
|
46
|
+
|
47
|
+
def site(name)
|
48
|
+
result = call("Webs", "get_web", "webUrl" => File.join(@url, name))
|
49
|
+
Site.new(connection, result.xpath("//sp:Web", NS).first["Url"].to_s, @depth + 1)
|
50
|
+
rescue Savon::SOAPFault
|
51
|
+
nil
|
52
|
+
end
|
53
|
+
|
54
|
+
def lists
|
55
|
+
result1 = call("Lists", "get_list_collection")
|
56
|
+
result2 = call("SiteData", "get_list_collection")
|
57
|
+
result2_by_id = {}
|
58
|
+
result2.xpath("//sp:_sList", NS).each do |element|
|
59
|
+
data = {}
|
60
|
+
element.children.each do |ch|
|
61
|
+
data[ch.name] = ch.inner_text
|
62
|
+
end
|
63
|
+
result2_by_id[data["InternalName"]] = data
|
64
|
+
end
|
65
|
+
result1.xpath("//sp:List", NS).select { |list| list["Title"] != "User Information List" }.map do |list|
|
66
|
+
List.new(self, list["ID"].to_s, list["Title"].to_s, clean_attributes(list.attributes), result2_by_id[list["ID"].to_s])
|
67
|
+
end
|
68
|
+
end
|
69
|
+
cache :lists, :dup => true
|
70
|
+
|
71
|
+
def list(name)
|
72
|
+
lists.find { |list| File.basename(list.attributes["RootFolder"]) == name }
|
73
|
+
end
|
74
|
+
|
75
|
+
def /(name)
|
76
|
+
list(name) || site(name)
|
77
|
+
end
|
78
|
+
|
79
|
+
def content_types
|
80
|
+
result = call("Webs", "get_content_types", "listName" => @id)
|
81
|
+
result.xpath("//sp:ContentType", NS).map do |content_type|
|
82
|
+
supersite && supersite.content_type(content_type["ID"]) || ContentType.new(self, nil, content_type["ID"], content_type["Name"], content_type["Description"], content_type["Version"], content_type["Group"])
|
83
|
+
end
|
84
|
+
end
|
85
|
+
cache :content_types, :dup => true
|
86
|
+
|
87
|
+
def content_type(id)
|
88
|
+
content_types.find { |t| t.id == id }
|
89
|
+
end
|
90
|
+
|
91
|
+
def permission_set
|
92
|
+
if attributes["InheritedSecurity"]
|
93
|
+
supersite.permission_set
|
94
|
+
else
|
95
|
+
PermissionSet.new(self)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
cache :permission_set
|
99
|
+
|
100
|
+
def fields
|
101
|
+
call("Webs", "get_columns").xpath("//sp:Field", NS).map do |field|
|
102
|
+
attributes = clean_attributes(field.attributes)
|
103
|
+
supersite && supersite.field(attributes["ID"].downcase) || Field.new(self, attributes["ID"].downcase, attributes["StaticName"], attributes["Type"], nil, attributes) if attributes["ID"] && attributes["StaticName"]
|
104
|
+
end.compact
|
105
|
+
end
|
106
|
+
cache :fields, :dup => true
|
107
|
+
|
108
|
+
def fields_by_name
|
109
|
+
fields.inject({}) { |h, f| h[f.attributes["StaticName"]] = f ; h }
|
110
|
+
end
|
111
|
+
cache :fields_by_name, :dup => true
|
112
|
+
|
113
|
+
def field(id)
|
114
|
+
fields.find { |f| f.ID == id }
|
115
|
+
end
|
116
|
+
|
117
|
+
def to_s
|
118
|
+
"#<ActiveSP::Site url=#{@url}>"
|
119
|
+
end
|
120
|
+
|
121
|
+
alias inspect to_s
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
def call(service, m, *args, &blk)
|
126
|
+
result = service(service).call(m, *args, &blk)
|
127
|
+
Nokogiri::XML.parse(result.http.body)
|
128
|
+
end
|
129
|
+
|
130
|
+
def fetch(url)
|
131
|
+
@connection.fetch(url)
|
132
|
+
end
|
133
|
+
|
134
|
+
def service(name)
|
135
|
+
@services[name] ||= Service.new(self, name)
|
136
|
+
end
|
137
|
+
|
138
|
+
def data
|
139
|
+
call("SiteData", "get_web")
|
140
|
+
end
|
141
|
+
cache :data
|
142
|
+
|
143
|
+
def attributes_before_type_cast
|
144
|
+
element = data.xpath("//sp:sWebMetadata", NS).first
|
145
|
+
result = {}
|
146
|
+
element.children.each do |ch|
|
147
|
+
result[ch.name] = ch.inner_text
|
148
|
+
end
|
149
|
+
result
|
150
|
+
end
|
151
|
+
cache :attributes_before_type_cast
|
152
|
+
|
153
|
+
def original_attributes
|
154
|
+
type_cast_attributes(self, nil, internal_attribute_types, attributes_before_type_cast)
|
155
|
+
end
|
156
|
+
cache :original_attributes
|
157
|
+
|
158
|
+
def internal_attribute_types
|
159
|
+
@@internal_attribute_types ||= {
|
160
|
+
"AllowAnonymousAccess" => GhostField.new("AllowAnonymousAccess", "Bool", false, true),
|
161
|
+
"AnonymousViewListItems" => GhostField.new("AnonymousViewListItems", "Bool", false, true),
|
162
|
+
"Author" => GhostField.new("Author", "InternalUser", false, true),
|
163
|
+
"Description" => GhostField.new("Description", "Text", false, true),
|
164
|
+
"ExternalSecurity" => GhostField.new("ExternalSecurity", "Bool", false, true),
|
165
|
+
"InheritedSecurity" => GhostField.new("InheritedSecurity", "Bool", false, true),
|
166
|
+
"IsBucketWeb" => GhostField.new("IsBucketWeb", "Bool", false, true),
|
167
|
+
"Language" => GhostField.new("Language", "Integer", false, true),
|
168
|
+
"LastModified" => GhostField.new("LastModified", "XMLDateTime", false, true),
|
169
|
+
"LastModifiedForceRecrawl" => GhostField.new("LastModifiedForceRecrawl", "XMLDateTime", false, true),
|
170
|
+
"Permissions" => GhostField.new("Permissions", "Text", false, true),
|
171
|
+
"Title" => GhostField.new("Title", "Text", false, true),
|
172
|
+
"UsedInAutocat" => GhostField.new("UsedInAutocat", "Bool", false, true),
|
173
|
+
"ValidSecurityInfo" => GhostField.new("ValidSecurityInfo", "Bool", false, true),
|
174
|
+
"WebID" => GhostField.new("WebID", "Text", false, true)
|
175
|
+
}
|
176
|
+
end
|
177
|
+
|
178
|
+
def permissions
|
179
|
+
result = call("Permissions", "get_permission_collection", "objectName" => File.basename(@url), "objectType" => "Web")
|
180
|
+
result.xpath("//spdir:Permission", NS).map do |row|
|
181
|
+
accessor = row["MemberIsUser"][/true/i] ? User.new(rootsite, row["UserLogin"]) : Group.new(rootsite, row["GroupName"])
|
182
|
+
{ :mask => Integer(row["Mask"]), :accessor => accessor }
|
183
|
+
end
|
184
|
+
end
|
185
|
+
cache :permissions, :dup => true
|
186
|
+
|
187
|
+
class Service
|
188
|
+
|
189
|
+
def initialize(site, name)
|
190
|
+
@site, @name = site, name
|
191
|
+
@client = Savon::Client.new(File.join(site.url, "_vti_bin", name + ".asmx?WSDL"))
|
192
|
+
@client.request.ntlm_auth(site.connection.login, site.connection.password) if site.connection.login
|
193
|
+
end
|
194
|
+
|
195
|
+
def call(m, *args)
|
196
|
+
t1 = Time.now
|
197
|
+
if Hash === args[-1]
|
198
|
+
body = args.pop
|
199
|
+
end
|
200
|
+
@client.send(m, *args) do |soap|
|
201
|
+
if body
|
202
|
+
soap.body = body.inject({}) { |h, (k, v)| h["wsdl:#{k}"] = v ; h }
|
203
|
+
end
|
204
|
+
yield soap if block_given?
|
205
|
+
end
|
206
|
+
ensure
|
207
|
+
t2 = Time.now
|
208
|
+
puts "SP - time: %.3fs, site: %s, service: %s, method: %s, body: %s" % [t2 - t1, @site.url, @name, m, body.inspect] if @site.connection.trace
|
209
|
+
end
|
210
|
+
|
211
|
+
end
|
212
|
+
|
213
|
+
end
|
214
|
+
|
215
|
+
end
|
data/lib/activesp/url.rb
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
def URL(*args)
|
2
|
+
case args.length
|
3
|
+
when 1
|
4
|
+
url = args[0]
|
5
|
+
if URL === url
|
6
|
+
url
|
7
|
+
else
|
8
|
+
URL.parse(url)
|
9
|
+
end
|
10
|
+
when 2..6
|
11
|
+
URL.new(*args)
|
12
|
+
else
|
13
|
+
raise ArgumentError, "wrong number of arguments (#{args.length} for 1..6)"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class URL < Struct.new(:protocol, :host, :port, :path, :query, :fragment)
|
18
|
+
|
19
|
+
def self.parse(url)
|
20
|
+
if /^(?:([^:\/?#]+):)?(?:\/\/([^\/?#:]*)(?::(\d+))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/ === url.strip
|
21
|
+
new($1 ? $1.downcase : nil, $2 ? $2.downcase : nil, $3 ? $3.to_i : nil, $4, $5, $6)
|
22
|
+
else
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_s
|
28
|
+
"%s://%s%s" % [protocol, authority, full_path]
|
29
|
+
end
|
30
|
+
|
31
|
+
def authority
|
32
|
+
"%s%s" % [host, (!port || port == (protocol == "http" ? 80 : 443)) ? "" : ":#{port}"]
|
33
|
+
end
|
34
|
+
|
35
|
+
def full_path
|
36
|
+
result = path.dup
|
37
|
+
result << "?" << query if query
|
38
|
+
result << "#" << fragment if fragment
|
39
|
+
result
|
40
|
+
end
|
41
|
+
|
42
|
+
def join(url)
|
43
|
+
url = URL(url)
|
44
|
+
if url
|
45
|
+
if url.protocol == protocol
|
46
|
+
url.protocol = nil
|
47
|
+
end
|
48
|
+
unless url.protocol
|
49
|
+
url.protocol = protocol
|
50
|
+
unless url.host
|
51
|
+
url.host = host
|
52
|
+
url.port = port
|
53
|
+
if url.path.empty?
|
54
|
+
url.path = path
|
55
|
+
unless url.query
|
56
|
+
url.query = query
|
57
|
+
end
|
58
|
+
else
|
59
|
+
url.path = join_url_paths(url.path, path)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
url.complete
|
64
|
+
else
|
65
|
+
nil
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def complete
|
70
|
+
self.protocol ||= "http"
|
71
|
+
self.port ||= self.protocol == "http" ? 80 : 443
|
72
|
+
self.path = "/" if self.path.empty?
|
73
|
+
self
|
74
|
+
end
|
75
|
+
|
76
|
+
def self.unescape(s)
|
77
|
+
s.gsub(/((?:%[0-9a-fA-F]{2})+)/n) do
|
78
|
+
[$1.delete('%')].pack('H*')
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.escape(s)
|
83
|
+
s.to_s.gsub(/([^a-zA-Z0-9_.-]+)/n) do
|
84
|
+
'%' + $1.unpack('H2' * $1.size).join('%').upcase
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.parse_query(qs, d = '&;')
|
89
|
+
params = {}
|
90
|
+
(qs || '').split(/[&;] */n).inject(params) do |h, p|
|
91
|
+
k, v = unescape(p).split('=', 2)
|
92
|
+
if cur = params[k]
|
93
|
+
if Array === cur
|
94
|
+
params[k] << v
|
95
|
+
else
|
96
|
+
params[k] = [cur, v]
|
97
|
+
end
|
98
|
+
else
|
99
|
+
params[k] = v
|
100
|
+
end
|
101
|
+
end
|
102
|
+
params
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.construct_query(hash)
|
106
|
+
hash.map { |k, v| "%s=%s" % [k, escape(v)] }.join('&')
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def join_url_paths(url, base)
|
112
|
+
if url[0] == ?/
|
113
|
+
url
|
114
|
+
else
|
115
|
+
base[0..base.rindex("/")] + url
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|