rets4r 0.8.5 → 1.1.18
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/.document +5 -0
- data/{test/client/data/1.5/metadata.xml → .gemtest} +0 -0
- data/CHANGELOG +611 -66
- data/CONTRIBUTORS +6 -2
- data/Gemfile +1 -0
- data/LICENSE +22 -0
- data/MANIFEST +63 -0
- data/NEWS +203 -0
- data/{README → README.rdoc} +11 -4
- data/RUBYS +7 -7
- data/Rakefile +48 -0
- data/TODO +5 -1
- data/examples/client_get_object.rb +31 -42
- data/examples/client_login.rb +20 -18
- data/examples/client_mapper.rb +17 -0
- data/examples/client_metadata.rb +28 -28
- data/examples/client_parser.rb +9 -0
- data/examples/client_search.rb +25 -27
- data/examples/settings.yml +114 -0
- data/lib/rets4r.rb +14 -1
- data/lib/rets4r/auth.rb +70 -66
- data/lib/rets4r/client.rb +470 -650
- data/lib/rets4r/client/data.rb +13 -13
- data/lib/rets4r/client/dataobject.rb +27 -19
- data/lib/rets4r/client/exceptions.rb +116 -0
- data/lib/rets4r/client/links.rb +32 -0
- data/lib/rets4r/client/metadata.rb +12 -12
- data/lib/rets4r/client/parsers/compact.rb +42 -0
- data/lib/rets4r/client/parsers/compact_nokogiri.rb +91 -0
- data/lib/rets4r/client/parsers/metadata.rb +92 -0
- data/lib/rets4r/client/parsers/response_parser.rb +100 -0
- data/lib/rets4r/client/requester.rb +143 -0
- data/lib/rets4r/client/transaction.rb +30 -33
- data/lib/rets4r/core_ext/array/extract_options.rb +15 -0
- data/lib/rets4r/core_ext/class/attribute_accessors.rb +58 -0
- data/lib/rets4r/core_ext/hash/keys.rb +46 -0
- data/lib/rets4r/core_ext/hash/slice.rb +39 -0
- data/lib/rets4r/listing_mapper.rb +17 -0
- data/lib/rets4r/listing_service.rb +35 -0
- data/lib/rets4r/loader.rb +8 -0
- data/lib/tasks/annotations.rake +121 -0
- data/lib/tasks/coverage.rake +13 -0
- data/rets4r.gemspec +24 -0
- data/spec/rets4r_compact_data_parser_spec.rb +7 -0
- data/test/data/1.5/bad_compact.xml +7 -0
- data/test/data/1.5/count_only_compact.xml +3 -0
- data/test/{client/data → data}/1.5/error.xml +0 -0
- data/test/{client/data → data}/1.5/invalid_compact.xml +0 -0
- data/test/{client/data → data}/1.5/login.xml +0 -0
- data/test/data/1.5/metadata.xml +0 -0
- data/test/{client/data → data}/1.5/search_compact.xml +0 -0
- data/test/data/1.5/search_compact_big.xml +136 -0
- data/test/{client/data → data}/1.5/search_unescaped_compact.xml +0 -0
- data/test/data/listing_service.yml +36 -0
- data/test/test_auth.rb +68 -0
- data/test/test_client.rb +342 -0
- data/test/test_client_links.rb +39 -0
- data/test/test_compact_nokogiri.rb +64 -0
- data/test/test_helper.rb +12 -0
- data/test/test_listing_mapper.rb +112 -0
- data/test/test_loader.rb +24 -0
- data/test/test_parser.rb +96 -0
- data/test/test_quality.rb +57 -0
- metadata +168 -53
- data/GPL +0 -340
- data/examples/metadata.xml +0 -42
- data/lib/rets4r/client/metadataindex.rb +0 -82
- data/lib/rets4r/client/parser.rb +0 -141
- data/lib/rets4r/client/parser/rexml.rb +0 -75
- data/lib/rets4r/client/parser/xmlparser.rb +0 -95
- data/test/client/parser/tc_rexml.rb +0 -17
- data/test/client/parser/tc_xmlparser.rb +0 -21
- data/test/client/tc_auth.rb +0 -68
- data/test/client/tc_client.rb +0 -320
- data/test/client/tc_metadataindex.rb +0 -36
- data/test/client/test_parser.rb +0 -128
- data/test/client/ts_all.rb +0 -8
- data/test/ts_all.rb +0 -1
- data/test/ts_client.rb +0 -1
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'rets4r/client/transaction'
|
2
|
+
require 'rets4r/client/parsers/compact'
|
3
|
+
require 'rexml/document'
|
4
|
+
|
5
|
+
module RETS4R
|
6
|
+
class Client
|
7
|
+
class ResponseParser
|
8
|
+
def parse_key_value(xml)
|
9
|
+
parse_common(xml) do |doc|
|
10
|
+
parsed = nil
|
11
|
+
first_child = doc.get_elements('/RETS/RETS-RESPONSE')[0] ? doc.get_elements('/RETS/RETS-RESPONSE')[0] : doc.get_elements('/RETS')[0]
|
12
|
+
unless first_child.nil?
|
13
|
+
parsed = {}
|
14
|
+
first_child.text.each do |line|
|
15
|
+
(key, value) = line.strip.split('=')
|
16
|
+
key.strip! if key
|
17
|
+
value.strip! if value
|
18
|
+
parsed[key] = value
|
19
|
+
end
|
20
|
+
else
|
21
|
+
raise 'Response was not a proper RETS XML doc!'
|
22
|
+
end
|
23
|
+
|
24
|
+
if parsed.nil?
|
25
|
+
raise "Response was not valid key/value format"
|
26
|
+
else
|
27
|
+
parsed
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def parse_results(xml, format)
|
33
|
+
parse_common(xml) do |doc|
|
34
|
+
parser = get_parser_by_name(format)
|
35
|
+
parser.parse_results(doc)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def parse_count(xml)
|
40
|
+
parse_common(xml) do |doc|
|
41
|
+
doc.get_elements('/RETS/COUNT')[0].attributes['Records']
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def parse_metadata(xml, format)
|
46
|
+
parse_common(xml) do |doc|
|
47
|
+
return REXML::Document.new(xml)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def parse_object_response(xml)
|
52
|
+
parse_common(xml) do |doc|
|
53
|
+
# XXX
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def parse_common(xml, &block)
|
60
|
+
if xml == ''
|
61
|
+
raise RETSException, 'No transaction body was returned!'
|
62
|
+
end
|
63
|
+
|
64
|
+
doc = REXML::Document.new(xml)
|
65
|
+
|
66
|
+
root = doc.root
|
67
|
+
if root.nil? || root.name != 'RETS'
|
68
|
+
raise "Response had invalid root node. Document was: #{doc.inspect}"
|
69
|
+
end
|
70
|
+
|
71
|
+
transaction = Transaction.new
|
72
|
+
transaction.reply_code = root.attributes['ReplyCode']
|
73
|
+
transaction.reply_text = root.attributes['ReplyText']
|
74
|
+
transaction.maxrows = (doc.get_elements('/RETS/MAXROWS').length > 0)
|
75
|
+
|
76
|
+
|
77
|
+
# XXX: If it turns out we need to parse the response of errors, then this will
|
78
|
+
# need to change.
|
79
|
+
if transaction.reply_code.to_i > 0 && transaction.reply_code.to_i != 20201
|
80
|
+
exception_type = Client::EXCEPTION_TYPES[transaction.reply_code.to_i] || RETSTransactionException
|
81
|
+
raise exception_type, "#{transaction.reply_code} - #{transaction.reply_text}"
|
82
|
+
end
|
83
|
+
|
84
|
+
transaction.response = yield doc
|
85
|
+
return transaction
|
86
|
+
end
|
87
|
+
|
88
|
+
def get_parser_by_name(name)
|
89
|
+
case name
|
90
|
+
when 'COMPACT', 'COMPACT-DECODED'
|
91
|
+
type = RETS4R::Client::CompactDataParser
|
92
|
+
else
|
93
|
+
raise "Invalid format #{name}"
|
94
|
+
end
|
95
|
+
type.new
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'rets4r'
|
2
|
+
require 'rets4r/client/exceptions'
|
3
|
+
|
4
|
+
module RETS4R
|
5
|
+
class Client
|
6
|
+
class Requester
|
7
|
+
DEFAULT_USER_AGENT = "rets4r/#{::RETS4R::VERSION}"
|
8
|
+
DEFAULT_RETS_VERSION = '1.7'
|
9
|
+
|
10
|
+
attr_accessor :logger, :headers, :pre_request_block, :nc, :username, :password, :method
|
11
|
+
def initialize
|
12
|
+
@nc = 0
|
13
|
+
@headers = {
|
14
|
+
'User-Agent' => DEFAULT_USER_AGENT,
|
15
|
+
'Accept' => '*/*',
|
16
|
+
'RETS-Version' => "RETS/#{DEFAULT_RETS_VERSION}",
|
17
|
+
}
|
18
|
+
@pre_request_block = nil
|
19
|
+
end
|
20
|
+
|
21
|
+
def user_agent
|
22
|
+
@headers['User-Agent']
|
23
|
+
end
|
24
|
+
|
25
|
+
def user_agent=(name)
|
26
|
+
set_header('User-Agent', name)
|
27
|
+
end
|
28
|
+
|
29
|
+
def rets_version=(version)
|
30
|
+
if (SUPPORTED_RETS_VERSIONS.include? version)
|
31
|
+
set_header('RETS-Version', "RETS/#{version}")
|
32
|
+
else
|
33
|
+
raise Unsupported.new("The client does not support RETS version '#{version}'.")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def rets_version
|
38
|
+
(@headers['RETS-Version'] || "").gsub("RETS/", "")
|
39
|
+
end
|
40
|
+
|
41
|
+
def set_header(name, value)
|
42
|
+
if value.nil? then
|
43
|
+
@headers.delete(name)
|
44
|
+
else
|
45
|
+
@headers[name] = value
|
46
|
+
end
|
47
|
+
|
48
|
+
logger.debug("Set header '#{name}' to '#{value}'") if logger
|
49
|
+
end
|
50
|
+
|
51
|
+
def user_agent
|
52
|
+
@headers['User-Agent']
|
53
|
+
end
|
54
|
+
|
55
|
+
# Given a hash, it returns a URL encoded query string.
|
56
|
+
def create_query_string(hash)
|
57
|
+
parts = hash.map {|key,value| "#{CGI.escape(key)}=#{CGI.escape(value)}" unless key.nil? || value.nil?}
|
58
|
+
return parts.join('&')
|
59
|
+
end
|
60
|
+
# This is the primary transaction method, which the other public methods make use of.
|
61
|
+
# Given a url for the transaction (endpoint) it makes a request to the RETS server.
|
62
|
+
#
|
63
|
+
#--
|
64
|
+
# This needs to be better documented, but for now please see the public transaction methods
|
65
|
+
# for how to make use of this method.
|
66
|
+
#++
|
67
|
+
def request(url, data = {}, header = {}, method = @method, retry_auth = DEFAULT_RETRY)
|
68
|
+
response = ''
|
69
|
+
|
70
|
+
http = Net::HTTP.new(url.host, url.port)
|
71
|
+
http.read_timeout = 600
|
72
|
+
|
73
|
+
if logger && logger.debug?
|
74
|
+
http.set_debug_output HTTPDebugLogger.new(logger)
|
75
|
+
end
|
76
|
+
|
77
|
+
http.start do |http|
|
78
|
+
begin
|
79
|
+
uri = url.path
|
80
|
+
|
81
|
+
if ! data.empty? && method == METHOD_GET
|
82
|
+
uri += "?#{create_query_string(data)}"
|
83
|
+
end
|
84
|
+
|
85
|
+
headers = @headers
|
86
|
+
headers.merge(header) unless header.empty?
|
87
|
+
|
88
|
+
@pre_request_block.call(self, http, headers) if @pre_request_block
|
89
|
+
|
90
|
+
logger.debug(headers.inspect) if logger
|
91
|
+
|
92
|
+
post_data = data.map {|k,v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join('&') if method == METHOD_POST
|
93
|
+
response = method == METHOD_POST ? http.post(uri, post_data, headers) :
|
94
|
+
http.get(uri, headers)
|
95
|
+
|
96
|
+
|
97
|
+
if response.code == '401'
|
98
|
+
# Authentication is required
|
99
|
+
raise AuthRequired
|
100
|
+
elsif response.code.to_i >= 300
|
101
|
+
# We have a non-successful response that we cannot handle
|
102
|
+
raise HTTPError.new(response)
|
103
|
+
else
|
104
|
+
cookies = []
|
105
|
+
if set_cookies = response.get_fields('set-cookie') then
|
106
|
+
set_cookies.each do |cookie|
|
107
|
+
cookies << cookie.split(";").first
|
108
|
+
end
|
109
|
+
end
|
110
|
+
set_header('Cookie', cookies.join("; ")) unless cookies.empty?
|
111
|
+
# totally wrong. session id is only ever under the Cookie header
|
112
|
+
#set_header('RETS-Session-ID', response['RETS-Session-ID']) if response['RETS-Session-ID']
|
113
|
+
set_header('RETS-Session-ID',nil)
|
114
|
+
end
|
115
|
+
rescue AuthRequired
|
116
|
+
@nc += 1
|
117
|
+
|
118
|
+
if retry_auth > 0
|
119
|
+
retry_auth -= 1
|
120
|
+
auth = Auth.authenticate(response,
|
121
|
+
@username,
|
122
|
+
@password,
|
123
|
+
url.path,
|
124
|
+
method,
|
125
|
+
@headers['RETS-Request-ID'],
|
126
|
+
@headers['User-Agent'],
|
127
|
+
@nc)
|
128
|
+
set_header('Authorization', auth)
|
129
|
+
retry
|
130
|
+
else
|
131
|
+
raise LoginError.new(response.message)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
logger.debug(response.body) if logger
|
136
|
+
end
|
137
|
+
|
138
|
+
return response
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -1,34 +1,31 @@
|
|
1
1
|
module RETS4R
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
end
|
33
|
-
end
|
34
|
-
end
|
2
|
+
class Client
|
3
|
+
class Transaction
|
4
|
+
attr_accessor :reply_code, :reply_text, :response, :metadata,
|
5
|
+
:header, :maxrows, :delimiter, :secondary_response
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
self.maxrows = false
|
9
|
+
self.header = []
|
10
|
+
self.delimiter = ?\t
|
11
|
+
end
|
12
|
+
|
13
|
+
def success?
|
14
|
+
return true if self.reply_code == '0'
|
15
|
+
return false
|
16
|
+
end
|
17
|
+
|
18
|
+
def maxrows?
|
19
|
+
return true if self.maxrows
|
20
|
+
return false
|
21
|
+
end
|
22
|
+
|
23
|
+
def ascii_delimiter
|
24
|
+
self.delimiter.chr
|
25
|
+
end
|
26
|
+
|
27
|
+
# For compatibility with the original library.
|
28
|
+
alias :data :response
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# from ActiveSupport
|
2
|
+
class Array
|
3
|
+
# Extracts options from a set of arguments. Removes and returns the last
|
4
|
+
# element in the array if it's a hash, otherwise returns a blank hash.
|
5
|
+
#
|
6
|
+
# def options(*args)
|
7
|
+
# args.extract_options!
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# options(1, 2) # => {}
|
11
|
+
# options(1, 2, :a => :b) # => {:a=>:b}
|
12
|
+
def extract_options!
|
13
|
+
last.is_a?(::Hash) ? pop : {}
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'rets4r/core_ext/array/extract_options'
|
2
|
+
# from ActiveSupport
|
3
|
+
|
4
|
+
# Extends the class object with class and instance accessors for class attributes,
|
5
|
+
# just like the native attr* accessors for instance attributes.
|
6
|
+
#
|
7
|
+
# class Person
|
8
|
+
# cattr_accessor :hair_colors
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# Person.hair_colors = [:brown, :black, :blonde, :red]
|
12
|
+
class Class
|
13
|
+
def cattr_reader(*syms)
|
14
|
+
syms.flatten.each do |sym|
|
15
|
+
next if sym.is_a?(Hash)
|
16
|
+
class_eval(<<-EOS, __FILE__, __LINE__ + 1)
|
17
|
+
unless defined? @@#{sym} # unless defined? @@hair_colors
|
18
|
+
@@#{sym} = nil # @@hair_colors = nil
|
19
|
+
end # end
|
20
|
+
#
|
21
|
+
def self.#{sym} # def self.hair_colors
|
22
|
+
@@#{sym} # @@hair_colors
|
23
|
+
end # end
|
24
|
+
#
|
25
|
+
def #{sym} # def hair_colors
|
26
|
+
@@#{sym} # @@hair_colors
|
27
|
+
end # end
|
28
|
+
EOS
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def cattr_writer(*syms)
|
33
|
+
options = syms.extract_options!
|
34
|
+
syms.flatten.each do |sym|
|
35
|
+
class_eval(<<-EOS, __FILE__, __LINE__ + 1)
|
36
|
+
unless defined? @@#{sym} # unless defined? @@hair_colors
|
37
|
+
@@#{sym} = nil # @@hair_colors = nil
|
38
|
+
end # end
|
39
|
+
#
|
40
|
+
def self.#{sym}=(obj) # def self.hair_colors=(obj)
|
41
|
+
@@#{sym} = obj # @@hair_colors = obj
|
42
|
+
end # end
|
43
|
+
#
|
44
|
+
#{" #
|
45
|
+
def #{sym}=(obj) # def hair_colors=(obj)
|
46
|
+
@@#{sym} = obj # @@hair_colors = obj
|
47
|
+
end # end
|
48
|
+
" unless options[:instance_writer] == false } # # instance writer above is generated unless options[:instance_writer] == false
|
49
|
+
EOS
|
50
|
+
self.send("#{sym}=", yield) if block_given?
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def cattr_accessor(*syms, &blk)
|
55
|
+
cattr_reader(*syms)
|
56
|
+
cattr_writer(*syms, &blk)
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# from ActiveSupport
|
2
|
+
class Hash
|
3
|
+
# Return a new hash with all keys converted to strings.
|
4
|
+
def stringify_keys
|
5
|
+
dup.stringify_keys!
|
6
|
+
end
|
7
|
+
|
8
|
+
# Destructively convert all keys to strings.
|
9
|
+
def stringify_keys!
|
10
|
+
keys.each do |key|
|
11
|
+
self[key.to_s] = delete(key)
|
12
|
+
end
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
# Return a new hash with all keys converted to symbols, as long as
|
17
|
+
# they respond to +to_sym+.
|
18
|
+
def symbolize_keys
|
19
|
+
dup.symbolize_keys!
|
20
|
+
end
|
21
|
+
|
22
|
+
# Destructively convert all keys to symbols, as long as they respond
|
23
|
+
# to +to_sym+.
|
24
|
+
def symbolize_keys!
|
25
|
+
keys.each do |key|
|
26
|
+
self[(key.to_sym rescue key) || key] = delete(key)
|
27
|
+
end
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
alias_method :to_options, :symbolize_keys
|
32
|
+
alias_method :to_options!, :symbolize_keys!
|
33
|
+
|
34
|
+
# Validate all keys in a hash match *valid keys, raising ArgumentError on a mismatch.
|
35
|
+
# Note that keys are NOT treated indifferently, meaning if you use strings for keys but assert symbols
|
36
|
+
# as keys, this will fail.
|
37
|
+
#
|
38
|
+
# ==== Examples
|
39
|
+
# { :name => "Rob", :years => "28" }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key(s): years"
|
40
|
+
# { :name => "Rob", :age => "28" }.assert_valid_keys("name", "age") # => raises "ArgumentError: Unknown key(s): name, age"
|
41
|
+
# { :name => "Rob", :age => "28" }.assert_valid_keys(:name, :age) # => passes, raises nothing
|
42
|
+
def assert_valid_keys(*valid_keys)
|
43
|
+
unknown_keys = keys - [valid_keys].flatten
|
44
|
+
raise(ArgumentError, "Unknown key(s): #{unknown_keys.join(", ")}") unless unknown_keys.empty?
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# from ActiveSupport
|
2
|
+
class Hash
|
3
|
+
# Slice a hash to include only the given keys. This is useful for
|
4
|
+
# limiting an options hash to valid keys before passing to a method:
|
5
|
+
#
|
6
|
+
# def search(criteria = {})
|
7
|
+
# assert_valid_keys(:mass, :velocity, :time)
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# search(options.slice(:mass, :velocity, :time))
|
11
|
+
#
|
12
|
+
# If you have an array of keys you want to limit to, you should splat them:
|
13
|
+
#
|
14
|
+
# valid_keys = [:mass, :velocity, :time]
|
15
|
+
# search(options.slice(*valid_keys))
|
16
|
+
def slice(*keys)
|
17
|
+
keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key)
|
18
|
+
hash = self.class.new
|
19
|
+
keys.each { |k| hash[k] = self[k] if has_key?(k) }
|
20
|
+
hash
|
21
|
+
end
|
22
|
+
|
23
|
+
# Replaces the hash with only the given keys.
|
24
|
+
# Returns a hash contained the removed key/value pairs
|
25
|
+
# {:a => 1, :b => 2, :c => 3, :d => 4}.slice!(:a, :b) # => {:c => 3, :d =>4}
|
26
|
+
def slice!(*keys)
|
27
|
+
keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key)
|
28
|
+
omit = slice(*self.keys - keys)
|
29
|
+
hash = slice(*keys)
|
30
|
+
replace(hash)
|
31
|
+
omit
|
32
|
+
end
|
33
|
+
|
34
|
+
def extract!(*keys)
|
35
|
+
result = {}
|
36
|
+
keys.each {|key| result[key] = delete(key) }
|
37
|
+
result
|
38
|
+
end
|
39
|
+
end
|