pyu-activesp 0.0.4.1.2

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,116 @@
1
+ # Copyright (c) 2010 XAOP bvba
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ #
17
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
20
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
21
+ #
22
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ # OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ module ActiveSP
27
+
28
+ class Role < Base
29
+
30
+ extend Caching
31
+ extend PersistentCaching
32
+ include Util
33
+ include InSite
34
+
35
+ persistent { |site, name, *a| [site.connection, [:role, name]] }
36
+ # @private
37
+ def initialize(site, name)
38
+ @site, @name = site, name
39
+ end
40
+
41
+ # See {Base#key}
42
+ # @return [String]
43
+ def key
44
+ encode_key("R", [@name])
45
+ end
46
+
47
+ # Returns the list of users in this role
48
+ # @return [User]
49
+ def users
50
+ call("UserGroup", "get_user_collection_from_role", "roleName" => @name).xpath("//spdir:User", NS).map do |row|
51
+ attributes = clean_attributes(row.attributes)
52
+ User.new(@site, attributes["LoginName"])
53
+ end
54
+ end
55
+ cache :users, :dup => :always
56
+
57
+ # Returns the list of groups in this role
58
+ # @return [Group]
59
+ def groups
60
+ call("UserGroup", "get_group_collection_from_role", "roleName" => @name).xpath("//spdir:Group", NS).map do |row|
61
+ attributes = clean_attributes(row.attributes)
62
+ Group.new(@site, attributes["Name"])
63
+ end
64
+ end
65
+ cache :groups, :dup => :always
66
+
67
+ # Returns true. The same method is present on {Group} where it returns false. Roles and groups can generally be
68
+ # duck-typed, and this method is there for the rare case where you do need to make the distinction
69
+ # @return [Boolean]
70
+ def is_role?
71
+ true
72
+ end
73
+
74
+ # See {Base#save}
75
+ # @return [void]
76
+ def save
77
+ p untype_cast_attributes(@site, nil, internal_attribute_types, changed_attributes)
78
+ end
79
+
80
+ # @private
81
+ def to_s
82
+ "#<ActiveSP::Role name=#{@name}>"
83
+ end
84
+
85
+ # @private
86
+ alias inspect to_s
87
+
88
+ private
89
+
90
+ def data
91
+ call("UserGroup", "get_role_info", "roleName" => @name).xpath("//spdir:Role", NS).first
92
+ end
93
+ cache :data
94
+
95
+ def attributes_before_type_cast
96
+ clean_attributes(data.attributes)
97
+ end
98
+ cache :attributes_before_type_cast
99
+
100
+ def original_attributes
101
+ type_cast_attributes(@site, nil, internal_attribute_types, attributes_before_type_cast)
102
+ end
103
+ cache :original_attributes
104
+
105
+ def internal_attribute_types
106
+ @@internal_attribute_types ||= {
107
+ "Description" => GhostField.new("Description", "Text", false, true),
108
+ "ID" => GhostField.new("ID", "Text", false, true),
109
+ "Name" => GhostField.new("Name", "Text", false, true),
110
+ "Type" => GhostField.new("Type", "Integer", false, true)
111
+ }
112
+ end
113
+
114
+ end
115
+
116
+ end
@@ -0,0 +1,128 @@
1
+ # Copyright (c) 2010 XAOP bvba
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ #
17
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
20
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
21
+ #
22
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ # OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ module ActiveSP
27
+
28
+ # @private
29
+ NS = {
30
+ "sp" => "http://schemas.microsoft.com/sharepoint/soap/",
31
+ "z" => "#RowsetSchema",
32
+ "spdir" => "http://schemas.microsoft.com/sharepoint/soap/directory/"
33
+ }
34
+
35
+ module Root
36
+
37
+ extend Caching
38
+
39
+ # Returns the root site as an object of class {Site}
40
+ # @return [Site]
41
+ def root
42
+ Site.new(self, @root_url)
43
+ end
44
+ cache :root
45
+
46
+ # Returns the list of users in the system
47
+ # @return [Array<User>]
48
+ def users
49
+ root.send(:call, "UserGroup", "get_user_collection_from_site").xpath("//spdir:User", NS).map do |row|
50
+ attributes = clean_attributes(row.attributes)
51
+ User.new(root, attributes["LoginName"])
52
+ end
53
+ end
54
+ cache :users, :dup => :always
55
+
56
+ # Returns the user with the given login, or nil when the user does not exist
57
+ # @param [String] login The login of the user
58
+ # @return [User, nil]
59
+ def user(login)
60
+ if user = users_by_login[login]
61
+ user
62
+ elsif data = root.send(:call, "UserGroup", "get_user_info", "userLoginName" => login).xpath("//spdir:User", NS).first
63
+ users_by_login[login] = User.new(root, login, clean_attributes(data))
64
+ end
65
+ end
66
+
67
+ # Returns the list of groups in the system
68
+ # @return [Array<Group>]
69
+ def groups
70
+ root.send(:call, "UserGroup", "get_group_collection_from_site").xpath("//spdir:Group", NS).map do |row|
71
+ attributes = clean_attributes(row.attributes)
72
+ Group.new(root, attributes["Name"])
73
+ end
74
+ end
75
+ cache :groups, :dup => :always
76
+
77
+ def group(name)
78
+ if group = groups_by_name[name]
79
+ group
80
+ end
81
+ end
82
+
83
+ # Returns the list of roles in the system
84
+ # @return [Array<Role>]
85
+ def roles
86
+ root.send(:call, "UserGroup", "get_role_collection_from_web").xpath("//spdir:Role", NS).map do |row|
87
+ attributes = clean_attributes(row.attributes)
88
+ Role.new(root, attributes["Name"])
89
+ end
90
+ end
91
+ cache :roles, :dup => :always
92
+
93
+ private
94
+
95
+ def users_by_login
96
+ users.inject({}) { |h, u| h[u.login_name] = u ; h }
97
+ end
98
+ cache :users_by_login
99
+
100
+ def groups_by_name
101
+ groups.inject({}) { |h, g| h[g.Name] = g ; h }
102
+ end
103
+ cache :groups_by_name
104
+
105
+ end
106
+
107
+ class Connection
108
+
109
+ include Root
110
+
111
+ end
112
+
113
+ # @private
114
+ module InSite
115
+
116
+ private
117
+
118
+ def call(*a, &b)
119
+ @site.send(:call, *a, &b)
120
+ end
121
+
122
+ def fetch(url)
123
+ @site.send(:fetch, url)
124
+ end
125
+
126
+ end
127
+
128
+ end
@@ -0,0 +1,305 @@
1
+ # Copyright (c) 2010 XAOP bvba
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ #
17
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
20
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
21
+ #
22
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ # OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ module ActiveSP
27
+
28
+ class Site < Base
29
+
30
+ extend Caching
31
+ extend PersistentCaching
32
+ include Util
33
+
34
+ # The URL of this site
35
+ # @return [String]
36
+ attr_reader :url
37
+ # @private
38
+ attr_reader :connection
39
+
40
+ persistent { |connection, url, *a| [connection, [:site, url]] }
41
+ # @private
42
+ def initialize(connection, url, depth = 0)
43
+ @connection, @url, @depth = connection, url, depth
44
+ @services = {}
45
+ end
46
+
47
+ # @private
48
+ def relative_url(url = @url)
49
+ url[@connection.root_url.rindex("/") + 1..-1]
50
+ end
51
+
52
+ # Returns the containing site, or nil if this is the root site
53
+ # @return [Site]
54
+ def supersite
55
+ unless is_root_site?
56
+ Site.new(@connection, ::File.dirname(@url), @depth - 1)
57
+ end
58
+ end
59
+ cache :supersite
60
+
61
+ # Returns the root site, or this site if it is the root site
62
+ # @return [Site]
63
+ def rootsite
64
+ is_root_site? ? self : supersite.rootsite
65
+ end
66
+ cache :rootsite
67
+
68
+ # Returns true if this site is the root site
69
+ # @return [Boolean]
70
+ def is_root_site?
71
+ @depth == 0
72
+ end
73
+
74
+ # See {Base#key}
75
+ # @return [String]
76
+ def key # This documentation is not ideal. The ideal doesn't work out of the box
77
+ encode_key("S", [@url[@connection.root_url.length + 1..-1], @depth])
78
+ end
79
+
80
+ # Returns the list of sites below this site. Does not recurse
81
+ # @return [Array<List>]
82
+ def sites
83
+ result = call("Webs", "get_web_collection")
84
+ result.xpath("//sp:Web", NS).map { |web| Site.new(connection, web["Url"].to_s, @depth + 1) }
85
+ end
86
+ cache :sites, :dup => :always
87
+
88
+ # Returns the site with the given name. This name is what appears in the URL as name and is immutable. Return nil
89
+ # if such a site does not exist
90
+ # @param [String] name The name if the site
91
+ # @return [Site]
92
+ def site(name)
93
+ result = call("Webs", "get_web", "webUrl" => ::File.join(@url, name))
94
+ Site.new(connection, result.xpath("//sp:Web", NS).first["Url"].to_s, @depth + 1)
95
+ rescue Savon::SOAPFault
96
+ nil
97
+ end
98
+
99
+ # Returns the list if lists in this sute. Does not recurse
100
+ # @return [Array<List>]
101
+ def lists
102
+ result1 = call("Lists", "get_list_collection")
103
+ result2 = call("SiteData", "get_list_collection")
104
+ result2_by_id = {}
105
+ result2.xpath("//sp:_sList", NS).each do |element|
106
+ data = {}
107
+ element.children.each do |ch|
108
+ data[ch.name] = ch.inner_text
109
+ end
110
+ result2_by_id[data["InternalName"]] = data
111
+ end
112
+ result1.xpath("//sp:List", NS).select { |list| list["Title"] != "User Information List" }.map do |list|
113
+ List.new(self, list["ID"].to_s, list["Title"].to_s, clean_attributes(list.attributes), result2_by_id[list["ID"].to_s])
114
+ end
115
+ end
116
+ cache :lists, :dup => :always
117
+
118
+ # Returns the list with the given name. The name is what appears in the URL as name and is immutable. Returns nil
119
+ # if such a list does not exist
120
+ # @param [String] name The name of the list
121
+ # @return [List]
122
+ def list(name)
123
+ lists.find { |list| ::File.basename(list.url) == name }
124
+ end
125
+
126
+ # Returns the site or list with the given name, or nil if it does not exist
127
+ # @param [String] name The name of the site or list
128
+ # @return [Site, List]
129
+ def /(name)
130
+ list(name) || site(name)
131
+ end
132
+
133
+ # Returns the list of content types defined for this site. These include the content types defined on
134
+ # containing sites as they are automatically inherited
135
+ # @return [Array<ContentType>]
136
+ def content_types
137
+ result = call("Webs", "get_content_types", "listName" => @id)
138
+ result.xpath("//sp:ContentType", NS).map do |content_type|
139
+ 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"])
140
+ end
141
+ end
142
+ cache :content_types, :dup => :always
143
+
144
+ # @private
145
+ def content_type(id)
146
+ content_types.find { |t| t.id == id }
147
+ end
148
+
149
+ # Returns the permission set associated with this site. This returns the permission set of
150
+ # the containing site if it does not have a permission set of its own
151
+ # @return [PermissionSet]
152
+ def permission_set
153
+ if attributes["InheritedSecurity"]
154
+ supersite.permission_set
155
+ else
156
+ PermissionSet.new(self)
157
+ end
158
+ end
159
+ cache :permission_set
160
+
161
+ # Returns the list of fields for this site. This includes fields inherited from containing sites
162
+ # @return [Array<Field>]
163
+ def fields
164
+ call("Webs", "get_columns").xpath("//sp:Field", NS).map do |field|
165
+ attributes = clean_attributes(field.attributes)
166
+ supersite && supersite.field(attributes["ID"].downcase) || Field.new(self, attributes["ID"].downcase, attributes["StaticName"], attributes["Type"], nil, attributes) if attributes["ID"] && attributes["StaticName"]
167
+ end.compact
168
+ end
169
+ cache :fields, :dup => :always
170
+
171
+ # Returns the result of {Site#fields} hashed by name
172
+ # @return [Hash{String => Field}]
173
+ def fields_by_name
174
+ fields.inject({}) { |h, f| h[f.attributes["StaticName"]] = f ; h }
175
+ end
176
+ cache :fields_by_name, :dup => :always
177
+
178
+ # @private
179
+ def field(id)
180
+ fields.find { |f| f.ID == id }
181
+ end
182
+
183
+ # See {Base#save}
184
+ # @return [void]
185
+ def save
186
+ p untype_cast_attributes(self, nil, internal_attribute_types, changed_attributes)
187
+ end
188
+
189
+ def accessible?
190
+ data
191
+ true
192
+ rescue Savon::HTTPError
193
+ false
194
+ end
195
+
196
+ # @private
197
+ def to_s
198
+ "#<ActiveSP::Site url=#{@url}>"
199
+ end
200
+
201
+ # @private
202
+ alias inspect to_s
203
+
204
+ private
205
+
206
+ def call(service, m, *args, &blk)
207
+ result = service(service).call(m, *args, &blk)
208
+ Nokogiri::XML.parse(result.http.body)
209
+ end
210
+
211
+ def fetch(url)
212
+ @connection.fetch(url)
213
+ end
214
+
215
+ def service(name)
216
+ @services[name] ||= Service.new(self, name)
217
+ end
218
+
219
+ def data
220
+ # Looks like you can't call this as a non-admin. To investigate further
221
+ call("SiteData", "get_web")
222
+ rescue Savon::HTTPError
223
+ # This can fail when you don't have access to this site
224
+ call("Webs", "get_web", "webUrl" => ".")
225
+ end
226
+ cache :data
227
+
228
+ def attributes_before_type_cast
229
+ if element = data.xpath("//sp:sWebMetadata", NS).first
230
+ result = {}
231
+ element.children.each do |ch|
232
+ result[ch.name] = ch.inner_text
233
+ end
234
+ result
235
+ else
236
+ element = data.xpath("//sp:Web", NS).first
237
+ clean_attributes(element.attributes)
238
+ end
239
+ end
240
+ cache :attributes_before_type_cast
241
+
242
+ def original_attributes
243
+ type_cast_attributes(self, nil, internal_attribute_types, attributes_before_type_cast)
244
+ end
245
+ cache :original_attributes
246
+
247
+ def internal_attribute_types
248
+ @@internal_attribute_types ||= {
249
+ "AllowAnonymousAccess" => GhostField.new("AllowAnonymousAccess", "Bool", false, true),
250
+ "AnonymousViewListItems" => GhostField.new("AnonymousViewListItems", "Bool", false, true),
251
+ "Author" => GhostField.new("Author", "InternalUser", false, true),
252
+ "Description" => GhostField.new("Description", "Text", false, true),
253
+ "ExternalSecurity" => GhostField.new("ExternalSecurity", "Bool", false, true),
254
+ "InheritedSecurity" => GhostField.new("InheritedSecurity", "Bool", false, true),
255
+ "IsBucketWeb" => GhostField.new("IsBucketWeb", "Bool", false, true),
256
+ "Language" => GhostField.new("Language", "Integer", false, true),
257
+ "LastModified" => GhostField.new("LastModified", "XMLDateTime", false, true),
258
+ "LastModifiedForceRecrawl" => GhostField.new("LastModifiedForceRecrawl", "XMLDateTime", false, true),
259
+ "Permissions" => GhostField.new("Permissions", "Text", false, true),
260
+ "Title" => GhostField.new("Title", "Text", false, true),
261
+ "UsedInAutocat" => GhostField.new("UsedInAutocat", "Bool", false, true),
262
+ "ValidSecurityInfo" => GhostField.new("ValidSecurityInfo", "Bool", false, true),
263
+ "WebID" => GhostField.new("WebID", "Text", false, true)
264
+ }
265
+ end
266
+
267
+ def permissions
268
+ result = call("Permissions", "get_permission_collection", "objectName" => ::File.basename(@url), "objectType" => "Web")
269
+ result.xpath("//spdir:Permission", NS).map do |row|
270
+ accessor = row["MemberIsUser"][/true/i] ? User.new(rootsite, row["UserLogin"]) : Group.new(rootsite, row["GroupName"])
271
+ { :mask => Integer(row["Mask"]), :accessor => accessor }
272
+ end
273
+ end
274
+ cache :permissions, :dup => :always
275
+
276
+ # @private
277
+ class Service
278
+
279
+ def initialize(site, name)
280
+ @site, @name = site, name
281
+ @client = Savon::Client.new(::File.join(URI.escape(site.url), "_vti_bin", name + ".asmx?WSDL"))
282
+ @client.request.ntlm_auth(site.connection.login, site.connection.password) if site.connection.login
283
+ end
284
+
285
+ def call(m, *args)
286
+ t1 = Time.now
287
+ if Hash === args[-1]
288
+ body = args.pop
289
+ end
290
+ @client.send(m, *args) do |soap|
291
+ if body
292
+ soap.body = body.inject({}) { |h, (k, v)| h["wsdl:#{k}"] = v ; h }
293
+ end
294
+ yield soap if block_given?
295
+ end
296
+ ensure
297
+ t2 = Time.now
298
+ puts "SP - time: %.3fs, site: %s, service: %s, method: %s, body: %s" % [t2 - t1, @site.url, @name, m, body.inspect] if @site.connection.trace
299
+ end
300
+
301
+ end
302
+
303
+ end
304
+
305
+ end
@@ -0,0 +1,146 @@
1
+ # Copyright (c) 2010 XAOP bvba
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ #
17
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
20
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
21
+ #
22
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ # OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ # @private
27
+ def URL(*args)
28
+ case args.length
29
+ when 1
30
+ url = args[0]
31
+ if URL === url
32
+ url
33
+ else
34
+ URL.parse(url)
35
+ end
36
+ when 2..6
37
+ URL.new(*args)
38
+ else
39
+ raise ArgumentError, "wrong number of arguments (#{args.length} for 1..6)"
40
+ end
41
+ end
42
+
43
+ # @private
44
+ class URL < Struct.new(:protocol, :host, :port, :path, :query, :fragment)
45
+
46
+ def self.parse(url)
47
+ if /^(?:([^:\/?#]+):)?(?:\/\/([^\/?#:]*)(?::(\d+))?)?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/ === url.strip
48
+ new($1 ? $1.downcase : nil, $2 ? $2.downcase : nil, $3 ? $3.to_i : nil, $4, $5, $6)
49
+ else
50
+ nil
51
+ end
52
+ end
53
+
54
+ def to_s
55
+ "%s://%s%s" % [protocol, authority, full_path]
56
+ end
57
+
58
+ def authority
59
+ "%s%s" % [host, (!port || port == (protocol == "http" ? 80 : 443)) ? "" : ":#{port}"]
60
+ end
61
+
62
+ def full_path
63
+ result = path.dup
64
+ result << "?" << query if query
65
+ result << "#" << fragment if fragment
66
+ result
67
+ end
68
+
69
+ def join(url)
70
+ url = URL(url)
71
+ if url
72
+ if url.protocol == protocol
73
+ url.protocol = nil
74
+ end
75
+ unless url.protocol
76
+ url.protocol = protocol
77
+ unless url.host
78
+ url.host = host
79
+ url.port = port
80
+ if url.path.empty?
81
+ url.path = path
82
+ unless url.query
83
+ url.query = query
84
+ end
85
+ else
86
+ url.path = join_url_paths(url.path, path)
87
+ end
88
+ end
89
+ end
90
+ url.complete
91
+ else
92
+ nil
93
+ end
94
+ end
95
+
96
+ def complete
97
+ self.protocol ||= "http"
98
+ self.port ||= self.protocol == "http" ? 80 : 443
99
+ self.path = "/" if self.path.empty?
100
+ self
101
+ end
102
+
103
+ def self.unescape(s)
104
+ s.gsub(/((?:%[0-9a-fA-F]{2})+)/n) do
105
+ [$1.delete('%')].pack('H*')
106
+ end
107
+ end
108
+
109
+ def self.escape(s)
110
+ s.to_s.gsub(/([^a-zA-Z0-9_.-]+)/n) do
111
+ '%' + $1.unpack('H2' * $1.size).join('%').upcase
112
+ end
113
+ end
114
+
115
+ def self.parse_query(qs, d = '&;')
116
+ params = {}
117
+ (qs || '').split(/[&;] */n).inject(params) do |h, p|
118
+ k, v = unescape(p).split('=', 2)
119
+ if cur = params[k]
120
+ if Array === cur
121
+ params[k] << v
122
+ else
123
+ params[k] = [cur, v]
124
+ end
125
+ else
126
+ params[k] = v
127
+ end
128
+ end
129
+ params
130
+ end
131
+
132
+ def self.construct_query(hash)
133
+ hash.map { |k, v| "%s=%s" % [k, escape(v)] }.join('&')
134
+ end
135
+
136
+ private
137
+
138
+ def join_url_paths(url, base)
139
+ if url[0] == ?/
140
+ url
141
+ else
142
+ base[0..base.rindex("/")] + url
143
+ end
144
+ end
145
+
146
+ end