rpw 0.0.3 → 1.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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +109 -0
- data/.gitignore +1 -0
- data/Gemfile.lock +12 -16
- data/HISTORY.md +21 -0
- data/exe/rpw +1 -252
- data/lib/rpw.rb +4 -374
- data/lib/rpw/README.md +58 -0
- data/lib/rpw/cli.rb +94 -0
- data/lib/rpw/cli/bannerlord.rb +59 -0
- data/lib/rpw/cli/key.rb +15 -0
- data/lib/rpw/cli/lesson.rb +101 -0
- data/lib/rpw/cli/progress.rb +34 -0
- data/lib/rpw/cli/quiz.rb +28 -0
- data/lib/rpw/cli/sub_command_base.rb +30 -0
- data/lib/rpw/client.rb +178 -0
- data/lib/rpw/client_data.rb +73 -0
- data/lib/rpw/gateway.rb +67 -0
- data/lib/rpw/version.rb +1 -1
- data/rpw.gemspec +1 -1
- metadata +15 -3
data/lib/rpw.rb
CHANGED
@@ -1,378 +1,8 @@
|
|
1
|
-
require "
|
2
|
-
require "
|
3
|
-
|
1
|
+
require "rpw/version"
|
2
|
+
require "rpw/client"
|
3
|
+
require "rpw/client_data"
|
4
|
+
require "rpw/gateway"
|
4
5
|
|
5
6
|
module RPW
|
6
7
|
class Error < StandardError; end
|
7
|
-
|
8
|
-
class Gateway
|
9
|
-
attr_accessor :domain
|
10
|
-
|
11
|
-
def initialize(domain, key)
|
12
|
-
@domain = domain
|
13
|
-
@key = key
|
14
|
-
end
|
15
|
-
|
16
|
-
class Error < StandardError; end
|
17
|
-
|
18
|
-
def authenticate_key(key)
|
19
|
-
Typhoeus.get(domain + "/license", userpwd: key + ":").success?
|
20
|
-
end
|
21
|
-
|
22
|
-
def get_content_by_position(position)
|
23
|
-
response = Typhoeus.get(domain + "/contents/positional?position=#{position}", userpwd: @key + ":")
|
24
|
-
if response.success?
|
25
|
-
JSON.parse(response.body)
|
26
|
-
else
|
27
|
-
puts response.inspect
|
28
|
-
raise Error, "There was a problem fetching this content."
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
def list_content
|
33
|
-
response = Typhoeus.get(domain + "/contents", userpwd: @key + ":")
|
34
|
-
if response.success?
|
35
|
-
JSON.parse(response.body)
|
36
|
-
else
|
37
|
-
puts response.inspect
|
38
|
-
raise Error, "There was a problem fetching this content."
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
def download_content(content, folder:)
|
43
|
-
puts "Downloading #{content["title"]}..."
|
44
|
-
downloaded_file = File.open("#{folder}/#{content["s3_key"]}", "w")
|
45
|
-
request = Typhoeus::Request.new(content["url"])
|
46
|
-
request.on_body do |chunk|
|
47
|
-
downloaded_file.write(chunk)
|
48
|
-
printf(".") if rand(500) == 0 # lol
|
49
|
-
end
|
50
|
-
request.on_complete { |response| downloaded_file.close }
|
51
|
-
request
|
52
|
-
end
|
53
|
-
|
54
|
-
def latest_version?
|
55
|
-
resp = Typhoeus.get("https://rubygems.org/api/v1/gems/rpw.json")
|
56
|
-
data = JSON.parse resp.body
|
57
|
-
Gem::Version.new(RPW::VERSION) >= Gem::Version.new(data["version"])
|
58
|
-
end
|
59
|
-
|
60
|
-
def register_email(email)
|
61
|
-
Typhoeus.put(domain + "/license", params: {email: email, key: @key})
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
class Client
|
66
|
-
RPW_SERVER_DOMAIN = ENV["RPW_SERVER_DOMAIN"] || "https://rpw-licensor.speedshop.co"
|
67
|
-
|
68
|
-
def setup(key)
|
69
|
-
gateway.authenticate_key(key)
|
70
|
-
client_data["key"] = key
|
71
|
-
end
|
72
|
-
|
73
|
-
def register_email(email)
|
74
|
-
gateway.register_email(email)
|
75
|
-
end
|
76
|
-
|
77
|
-
def directory_setup
|
78
|
-
["video", "quiz", "lab", "text", "cgrp"].each do |path|
|
79
|
-
FileUtils.mkdir_p(path) unless File.directory?(path)
|
80
|
-
end
|
81
|
-
|
82
|
-
client_data["completed"] = [] # just to write the file
|
83
|
-
|
84
|
-
unless File.exist?(".gitignore") && File.read(".gitignore").match(/rpw_key/)
|
85
|
-
File.open(".gitignore", "a") do |f|
|
86
|
-
f.puts "\n"
|
87
|
-
f.puts ".rpw_key\n"
|
88
|
-
f.puts ".rpw_info\n"
|
89
|
-
f.puts "video\n"
|
90
|
-
f.puts "quiz\n"
|
91
|
-
f.puts "lab\n"
|
92
|
-
f.puts "text\n"
|
93
|
-
f.puts "cgrp\n"
|
94
|
-
end
|
95
|
-
end
|
96
|
-
end
|
97
|
-
|
98
|
-
def next(open_after = false)
|
99
|
-
complete
|
100
|
-
content = next_content
|
101
|
-
if content.nil?
|
102
|
-
finished_workshop
|
103
|
-
return
|
104
|
-
end
|
105
|
-
|
106
|
-
unless File.exist?(content["style"] + "/" + content["s3_key"])
|
107
|
-
gateway.download_content(content, folder: content["style"]).run
|
108
|
-
extract_content(content) if content["s3_key"].end_with?(".tar.gz")
|
109
|
-
end
|
110
|
-
client_data["current_lesson"] = content["position"]
|
111
|
-
display_content(content, open_after)
|
112
|
-
end
|
113
|
-
|
114
|
-
def complete
|
115
|
-
reset_progress unless client_data["current_lesson"] && client_data["completed"]
|
116
|
-
client_data["completed"] ||= []
|
117
|
-
client_data["completed"] += [client_data["current_lesson"] || 0]
|
118
|
-
end
|
119
|
-
|
120
|
-
def list
|
121
|
-
gateway.list_content
|
122
|
-
end
|
123
|
-
|
124
|
-
def show(content_pos, open_after = false)
|
125
|
-
content_pos = client_data["current_lesson"] if content_pos == :current
|
126
|
-
content = gateway.get_content_by_position(content_pos)
|
127
|
-
unless File.exist?(content["style"] + "/" + content["s3_key"])
|
128
|
-
gateway.download_content(content, folder: content["style"]).run
|
129
|
-
extract_content(content) if content["s3_key"].end_with?(".tar.gz")
|
130
|
-
end
|
131
|
-
client_data["current_lesson"] = content["position"]
|
132
|
-
display_content(content, open_after)
|
133
|
-
end
|
134
|
-
|
135
|
-
def download(content_pos)
|
136
|
-
if content_pos.downcase == "all"
|
137
|
-
to_download = gateway.list_content
|
138
|
-
hydra = Typhoeus::Hydra.new(max_concurrency: 5)
|
139
|
-
to_download.each do |content|
|
140
|
-
unless File.exist?(content["style"] + "/" + content["s3_key"])
|
141
|
-
hydra.queue gateway.download_content(content, folder: content["style"])
|
142
|
-
end
|
143
|
-
end
|
144
|
-
hydra.run
|
145
|
-
to_download.each { |content| extract_content(content) if content["s3_key"].end_with?(".tar.gz") }
|
146
|
-
else
|
147
|
-
content = gateway.get_content_by_position(content_pos)
|
148
|
-
unless File.exist?(content["style"] + "/" + content["s3_key"])
|
149
|
-
gateway.download_content(content, folder: content["style"]).run
|
150
|
-
extract_content(content) if content["s3_key"].end_with?(".tar.gz")
|
151
|
-
end
|
152
|
-
end
|
153
|
-
end
|
154
|
-
|
155
|
-
def progress
|
156
|
-
contents = gateway.list_content
|
157
|
-
{
|
158
|
-
completed: client_data["completed"].size,
|
159
|
-
total: contents.size,
|
160
|
-
current_lesson: contents.find { |c| c["position"] == client_data["current_lesson"] },
|
161
|
-
sections: chart_section_progress(contentsi)
|
162
|
-
}
|
163
|
-
end
|
164
|
-
|
165
|
-
def set_progress(lesson)
|
166
|
-
client_data["current_lesson"] = lesson.to_i
|
167
|
-
end
|
168
|
-
|
169
|
-
def reset_progress
|
170
|
-
client_data["current_lesson"] = 0
|
171
|
-
client_data["completed"] = []
|
172
|
-
end
|
173
|
-
|
174
|
-
def latest_version?
|
175
|
-
if client_data["last_version_check"]
|
176
|
-
return true if client_data["last_version_check"] >= Time.now - (60 * 60 * 24)
|
177
|
-
return false if client_data["last_version_check"] == false
|
178
|
-
end
|
179
|
-
|
180
|
-
begin
|
181
|
-
latest = gateway.latest_version?
|
182
|
-
rescue
|
183
|
-
return true
|
184
|
-
end
|
185
|
-
|
186
|
-
client_data["last_version_check"] = if latest
|
187
|
-
Time.now
|
188
|
-
else
|
189
|
-
false
|
190
|
-
end
|
191
|
-
end
|
192
|
-
|
193
|
-
def setup?
|
194
|
-
client_data["key"]
|
195
|
-
end
|
196
|
-
|
197
|
-
private
|
198
|
-
|
199
|
-
def finished_workshop
|
200
|
-
RPW::CLI.new.print_banner
|
201
|
-
puts "Congratulations!"
|
202
|
-
puts "You have completed the Rails Performance Workshop."
|
203
|
-
end
|
204
|
-
|
205
|
-
def chart_section_progress(contents)
|
206
|
-
contents.group_by { |c| c["position"] / 100 }
|
207
|
-
.each_with_object([]) do |(_, c), memo|
|
208
|
-
completed_str = c.map { |l|
|
209
|
-
if l["position"] == client_data["current_lesson"]
|
210
|
-
"O"
|
211
|
-
elsif client_data["completed"].include?(l["position"])
|
212
|
-
"X"
|
213
|
-
else
|
214
|
-
"."
|
215
|
-
end
|
216
|
-
}.join
|
217
|
-
memo << {
|
218
|
-
title: c[0]["title"],
|
219
|
-
progress: completed_str
|
220
|
-
}
|
221
|
-
end
|
222
|
-
end
|
223
|
-
|
224
|
-
def next_content
|
225
|
-
contents = gateway.list_content
|
226
|
-
return contents.first unless client_data["completed"]
|
227
|
-
contents.delete_if { |c| client_data["completed"].include? c["position"] }
|
228
|
-
contents.min_by { |c| c["position"] }
|
229
|
-
end
|
230
|
-
|
231
|
-
def client_data
|
232
|
-
@client_data ||= ClientData.new
|
233
|
-
end
|
234
|
-
|
235
|
-
def gateway
|
236
|
-
@gateway ||= Gateway.new(RPW_SERVER_DOMAIN, client_data["key"])
|
237
|
-
end
|
238
|
-
|
239
|
-
def extract_content(content)
|
240
|
-
folder = content["style"]
|
241
|
-
`tar -C #{folder} -xvzf #{folder}/#{content["s3_key"]}`
|
242
|
-
end
|
243
|
-
|
244
|
-
def display_content(content, open_after)
|
245
|
-
puts "\nCurrent Lesson: #{content["title"]}"
|
246
|
-
case content["style"]
|
247
|
-
when "video"
|
248
|
-
location = "video/#{content["s3_key"]}"
|
249
|
-
when "quiz"
|
250
|
-
Quiz.start(["give_quiz", "quiz/" + content["s3_key"]])
|
251
|
-
when "lab"
|
252
|
-
location = "lab/#{content["s3_key"]}"
|
253
|
-
when "text"
|
254
|
-
location = "lab/#{content["s3_key"]}"
|
255
|
-
when "cgrp"
|
256
|
-
puts "The Complete Guide to Rails Performance has been downloaded and extracted to the ./cgrp directory."
|
257
|
-
puts "All source code for the CGRP is in the src directory, PDF and other compiled formats are in the release directory."
|
258
|
-
end
|
259
|
-
if location
|
260
|
-
puts "Downloaded to:"
|
261
|
-
puts location.to_s
|
262
|
-
if open_after
|
263
|
-
exec "#{open_command} #{location}"
|
264
|
-
end
|
265
|
-
end
|
266
|
-
end
|
267
|
-
|
268
|
-
require "rbconfig"
|
269
|
-
def open_command
|
270
|
-
host_os = RbConfig::CONFIG["host_os"]
|
271
|
-
case host_os
|
272
|
-
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
|
273
|
-
"start"
|
274
|
-
when /darwin|mac os/
|
275
|
-
"open"
|
276
|
-
else
|
277
|
-
"xdg-open"
|
278
|
-
end
|
279
|
-
end
|
280
|
-
end
|
281
|
-
|
282
|
-
require "fileutils"
|
283
|
-
require "yaml"
|
284
|
-
|
285
|
-
class ClientData
|
286
|
-
DOTFILE_NAME = ".rpw_info"
|
287
|
-
|
288
|
-
def initialize
|
289
|
-
make_sure_dotfile_exists
|
290
|
-
data # access file to load
|
291
|
-
end
|
292
|
-
|
293
|
-
def [](key)
|
294
|
-
data[key]
|
295
|
-
end
|
296
|
-
|
297
|
-
def []=(key, value)
|
298
|
-
data
|
299
|
-
data[key] = value
|
300
|
-
|
301
|
-
begin
|
302
|
-
File.open(filestore_location, "w") { |f| f.write(YAML.dump(data)) }
|
303
|
-
rescue
|
304
|
-
raise Error, "The RPW data at #{filestore_location} is not writable. \
|
305
|
-
Check your file permissions."
|
306
|
-
end
|
307
|
-
end
|
308
|
-
|
309
|
-
def self.delete_filestore
|
310
|
-
return unless File.exist?(filestore_location)
|
311
|
-
FileUtils.remove(filestore_location)
|
312
|
-
end
|
313
|
-
|
314
|
-
def self.filestore_location
|
315
|
-
if File.exist?(File.expand_path("./" + self::DOTFILE_NAME))
|
316
|
-
File.expand_path("./" + + self::DOTFILE_NAME)
|
317
|
-
else
|
318
|
-
File.expand_path("~/.rpw/" + self::DOTFILE_NAME)
|
319
|
-
end
|
320
|
-
end
|
321
|
-
|
322
|
-
private
|
323
|
-
|
324
|
-
def filestore_location
|
325
|
-
self.class.filestore_location
|
326
|
-
end
|
327
|
-
|
328
|
-
def create_client_data_directory(path)
|
329
|
-
dirname = File.dirname(path)
|
330
|
-
FileUtils.mkdir_p(dirname) unless File.directory?(dirname)
|
331
|
-
end
|
332
|
-
|
333
|
-
def data
|
334
|
-
@data ||= begin
|
335
|
-
yaml = YAML.safe_load(File.read(filestore_location), permitted_classes: [Time])
|
336
|
-
yaml || {}
|
337
|
-
end
|
338
|
-
end
|
339
|
-
|
340
|
-
def make_sure_dotfile_exists
|
341
|
-
return true if File.exist?(filestore_location)
|
342
|
-
create_client_data_directory(filestore_location)
|
343
|
-
begin
|
344
|
-
FileUtils.touch(filestore_location)
|
345
|
-
rescue
|
346
|
-
raise Error, "Could not create the RPW data file at ~/.rpw/ \
|
347
|
-
Check your file permissions."
|
348
|
-
end
|
349
|
-
end
|
350
|
-
end
|
351
|
-
|
352
|
-
require "digest"
|
353
|
-
require "thor"
|
354
|
-
|
355
|
-
class Quiz < Thor
|
356
|
-
desc "give_quiz FILENAME", ""
|
357
|
-
def give_quiz(filename)
|
358
|
-
@quiz_data = YAML.safe_load(File.read(filename))
|
359
|
-
@quiz_data["questions"].each { |q| question(q) }
|
360
|
-
end
|
361
|
-
|
362
|
-
private
|
363
|
-
|
364
|
-
def question(data)
|
365
|
-
puts data["prompt"]
|
366
|
-
data["answer_choices"].each { |ac| puts ac }
|
367
|
-
provided_answer = ask("Your answer?")
|
368
|
-
answer_digest = Digest::MD5.hexdigest(data["prompt"] + provided_answer.upcase)
|
369
|
-
if answer_digest == data["answer_digest"]
|
370
|
-
say "Correct!"
|
371
|
-
else
|
372
|
-
say "Incorrect."
|
373
|
-
say "I encourage you to try reviewing the material to see what the correct answer is."
|
374
|
-
end
|
375
|
-
say ""
|
376
|
-
end
|
377
|
-
end
|
378
8
|
end
|
data/lib/rpw/README.md
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
## Installation Requirements
|
2
|
+
|
3
|
+
This client assumes you're using Ruby 2.3 or later.
|
4
|
+
|
5
|
+
This client assumes you have `tar` installed and available on your PATH.
|
6
|
+
|
7
|
+
The way that the client opens files (using `open` or `xdg-open` depending on platform) assumes you have your default program for the following filetypes set correctly:
|
8
|
+
|
9
|
+
* .md for Markdown (XCode by default on Mac: you probably want to change that!)
|
10
|
+
* .mp4 for videos
|
11
|
+
|
12
|
+
## Slack Invite
|
13
|
+
|
14
|
+
If you purchased the Workshop yourself, you will receive a Slack channel invitation
|
15
|
+
shortly. If you are attending the Workshop as part of a group and your license key
|
16
|
+
was provided to you, you need to register your key to get an invite:
|
17
|
+
|
18
|
+
```
|
19
|
+
$ rpw key register [YOUR_EMAIL_ADDRESS]
|
20
|
+
```
|
21
|
+
|
22
|
+
Please note you can only register your key once.
|
23
|
+
|
24
|
+
The Slack channel is your best resource for questions about Rails Performance
|
25
|
+
or other material in the workshop. Nate is almost always monitoring that channel.
|
26
|
+
|
27
|
+
If you encounter a **bug or other software problem**, please email support@speedshop.co.
|
28
|
+
|
29
|
+
## Important Commands
|
30
|
+
|
31
|
+
Here are some important commands for you to know:
|
32
|
+
|
33
|
+
```
|
34
|
+
$ rpw lesson next | Proceed to the next part of the workshop.
|
35
|
+
$ rpw lesson complete | Mark current lesson as complete.
|
36
|
+
$ rpw lesson list | List all workshop lessons. Note each lesson is preceded with an ID.
|
37
|
+
$ rpw lesson download | Download any or all lessons. Use the IDs from "list".
|
38
|
+
$ rpw lesson show | Show any particular workshop lesson. Use the IDs from "list".
|
39
|
+
$ rpw progress | Show where you're currently at in the workshop.
|
40
|
+
```
|
41
|
+
|
42
|
+
Generally, you'll just be doing a lot of `$ rpw lesson next`!
|
43
|
+
|
44
|
+
By default, `$ rpw lesson next` will try to open the content it downloads. If you
|
45
|
+
either don't like this, or for some reason it doesn't work, use `$ rpw lesson next --no-open`.
|
46
|
+
|
47
|
+
## Working Offline
|
48
|
+
|
49
|
+
By default, the course will download each piece of content as you progress through
|
50
|
+
the course. However, you can use `rpw lesson download all` to download all content
|
51
|
+
at once, and complete the workshop entirely offline.
|
52
|
+
|
53
|
+
Videos in this workshop are generally about 100MB each, which means the entire
|
54
|
+
course is about a 3 to 4GB download.
|
55
|
+
|
56
|
+
## Bugs and Support
|
57
|
+
|
58
|
+
If you encounter any problems, please email support@speedshop.co for the fastest possible response.
|
data/lib/rpw/cli.rb
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
require "thor"
|
2
|
+
require "thor/hollaback"
|
3
|
+
require "rpw"
|
4
|
+
require "rpw/cli/bannerlord"
|
5
|
+
require "rpw/cli/sub_command_base"
|
6
|
+
require "rpw/cli/key"
|
7
|
+
require "rpw/cli/lesson"
|
8
|
+
require "rpw/cli/progress"
|
9
|
+
|
10
|
+
module RPW
|
11
|
+
class CLI < Thor
|
12
|
+
class_before :check_version
|
13
|
+
class_before :check_setup
|
14
|
+
|
15
|
+
desc "key register [EMAIL_ADDRESS]", "Change email registered w/Speedshop"
|
16
|
+
subcommand "key", Key
|
17
|
+
desc "lesson [SUBCOMMAND]", "View and download lessons"
|
18
|
+
subcommand "lesson", Lesson
|
19
|
+
desc "progress [SUBCOMMAND]", "View and set progress"
|
20
|
+
subcommand "progress", Progress
|
21
|
+
|
22
|
+
def self.exit_on_failure?
|
23
|
+
true
|
24
|
+
end
|
25
|
+
|
26
|
+
desc "start", "Tutorial and onboarding"
|
27
|
+
def start
|
28
|
+
warn_if_already_started
|
29
|
+
|
30
|
+
print_banner
|
31
|
+
say "Welcome to the Rails Performance Workshop."
|
32
|
+
say ""
|
33
|
+
say "This is rpw, the command line client for this workshop."
|
34
|
+
say ""
|
35
|
+
say "This client will download files from the internet into the current"
|
36
|
+
say "working directory, so it's best to run this client from a new directory"
|
37
|
+
say "that you'll use as your 'scratch space' for working on the Workshop."
|
38
|
+
say ""
|
39
|
+
say "We will create a handful of new files and folders in the current directory."
|
40
|
+
return unless yes? "Is this OK? (y/N) (N will quit)"
|
41
|
+
puts ""
|
42
|
+
say "We'll also create a .rpw_info file at #{File.expand_path("~/.rpw")} to save your purchase key."
|
43
|
+
home_dir_ok = yes?("Is this OK? (y/N) (N will create it in the current directory)")
|
44
|
+
client.directory_setup(home_dir_ok)
|
45
|
+
|
46
|
+
key = ask("Your Purchase Key: ")
|
47
|
+
|
48
|
+
unless client.setup(key)
|
49
|
+
say "That is not a valid key. Please try again."
|
50
|
+
exit(0)
|
51
|
+
end
|
52
|
+
|
53
|
+
puts ""
|
54
|
+
say "Successfully authenticated with the RPW server and saved your key."
|
55
|
+
puts ""
|
56
|
+
say "Setup complete!"
|
57
|
+
puts ""
|
58
|
+
say "To learn how to use this command-line client, consult ./README.md, which we just created."
|
59
|
+
say "Once you've read that and you're ready to get going: $ rpw lesson next"
|
60
|
+
end
|
61
|
+
|
62
|
+
no_commands do
|
63
|
+
def print_banner
|
64
|
+
RPW::Bannerlord.print_banner
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def client
|
71
|
+
@client ||= RPW::Client.new
|
72
|
+
end
|
73
|
+
|
74
|
+
def warn_if_already_started
|
75
|
+
return unless client.setup?
|
76
|
+
exit(0) unless yes? "You have already started the workshop. Continuing "\
|
77
|
+
"this command will wipe all of your current progress. Continue? (y/N)"
|
78
|
+
end
|
79
|
+
|
80
|
+
def check_version
|
81
|
+
unless client.latest_version?
|
82
|
+
say "WARNING: You are running an old version of rpw."
|
83
|
+
say "WARNING: Please run `$ gem install rpw`"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def check_setup
|
88
|
+
unless client.setup? || current_command_chain == [:start]
|
89
|
+
say "WARNING: You do not have a purchase key set. Run `$ rpw start`"
|
90
|
+
exit(0)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|