activesp 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.
- 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
|