drive 0.3.0 → 0.5.0
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/CHANGELOG.md +20 -0
- data/README.md +13 -27
- data/app/models/concerns/counted.rb +17 -0
- data/app/models/concerns/mirrored.rb +8 -17
- data/app/models/crawler.rb +24 -23
- data/app/models/run.rb +1 -4
- data/db/migrate/20261006100000_drop_changes.rb +16 -0
- data/lib/drive/engine.rb +2 -2
- data/lib/drive/version.rb +1 -1
- data/lib/drive.rb +1 -1
- metadata +6 -7
- data/app/controllers/feeds_controller.rb +0 -25
- data/app/models/change.rb +0 -19
- data/app/models/concerns/logged.rb +0 -26
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99a4adcee91a1d02137f10f2ffff028ee3bcabd96896d550c71e81c443ad47b8
|
|
4
|
+
data.tar.gz: 2a5e5b329808e3e315464d70e1e8fe1357a5736632d96dde4077f3f36a57518e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 57b24c0022b7476eb3db58ab1108ee86283f5397a49704c39626f751c8158baad8d35404ceebafedaf53bbefc912d5a99e65ddbe6cd6cfea546b8417b20320d8
|
|
7
|
+
data.tar.gz: eeaa76fd2453f73da3332ded46d662d4165b458ea9db7359582ec6c05b980db96446eaccf47ed880893d1f4298fa37876c9fb573a7b9d399953d43daa98a29a4
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## 0.5.0 - 2026-10-07
|
|
10
|
+
|
|
11
|
+
* [FEATURE] `RateLimited` no longer limits Claude's, ChatGPT's or Perplexity's search crawler
|
|
12
|
+
|
|
13
|
+
`Crawler.verified?(request)` believes a user agent naming Claude-SearchBot, OAI-SearchBot or
|
|
14
|
+
PerplexityBot, as well as Google's, Bing's or Apple's crawler, only where its address is in the
|
|
15
|
+
ranges its search engine publishes as JSON, fetched once a day. None of the three new ones
|
|
16
|
+
resolves to a domain of its own, so no crawler is checked by DNS any more.
|
|
17
|
+
|
|
18
|
+
## 0.4.0 - 2026-10-06
|
|
19
|
+
|
|
20
|
+
* [BREAKING] No more log of changes, and no feed
|
|
21
|
+
|
|
22
|
+
Nothing followed the feed: the apps that read a mirror read its tables directly, from the same
|
|
23
|
+
database. `Change`, `Change.prune`, `FeedsController` and the `changes` table go;
|
|
24
|
+
`bin/rails drive:install:migrations db:migrate` copies and runs the migration that drops them.
|
|
25
|
+
`Mirrored` counts the rows a refresh inserts, updates and deletes instead of logging them, and
|
|
26
|
+
`Logged` is now `Counted`, which counts each save and delete in the run at hand; a table
|
|
27
|
+
including it no longer needs `KEY` or `SOURCE`.
|
|
28
|
+
|
|
9
29
|
## 0.3.0 - 2026-10-06
|
|
10
30
|
|
|
11
31
|
* [FEATURE] `RateLimited` no longer limits a search engine's own crawler
|
data/README.md
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
# Drive
|
|
2
2
|
|
|
3
3
|
A Rails engine for an app that keeps a copy of what somebody else publishes — a government's
|
|
4
|
-
files, a monthly database —
|
|
4
|
+
files, a monthly database — changing only the rows the source did, and counting them.
|
|
5
5
|
|
|
6
6
|
- **`Mirrored`**: a table copying one of the source's tables field for field, as text. A refresh
|
|
7
7
|
stages the source's table, then inserts, updates and deletes only the rows the source did.
|
|
8
|
-
- **`
|
|
9
|
-
- **`Run`**: one refresh, in one transaction, the only one running
|
|
10
|
-
|
|
11
|
-
- **`FeedsController`**: `GET /feed?after=ID&kind=TABLE`, the next 5,000 changes, as JSON.
|
|
8
|
+
- **`Counted`**: a table written a record at a time, such as a document fetched by URL.
|
|
9
|
+
- **`Run`**: one refresh, in one transaction, the only one running, with how many rows it
|
|
10
|
+
inserted, updated and deleted.
|
|
12
11
|
- **`AccessFile`**: stages a table of a Microsoft Access file, read with `mdb-export`.
|
|
13
12
|
- **`RateLimited`**: 20 requests a minute and 200 a day per address, for public pages. A search
|
|
14
13
|
engine's own crawler has no limit.
|
|
15
|
-
- **`Crawler`**: whether a request comes from Google
|
|
16
|
-
|
|
14
|
+
- **`Crawler`**: whether a request comes from the crawler of Google, Bing, Apple, Claude, ChatGPT
|
|
15
|
+
or Perplexity: its address is in the ranges they publish, fetched once a day.
|
|
17
16
|
|
|
18
17
|
It needs PostgreSQL.
|
|
19
18
|
|
|
@@ -25,12 +24,12 @@ gem install drive
|
|
|
25
24
|
|
|
26
25
|
```ruby
|
|
27
26
|
# Gemfile
|
|
28
|
-
gem 'drive', '~> 0.
|
|
27
|
+
gem 'drive', '~> 0.5.0'
|
|
29
28
|
```
|
|
30
29
|
|
|
31
|
-
`~> 0.
|
|
30
|
+
`~> 0.5.0` takes every 0.5.x and stops short of 0.6, which may break what 0.5 does.
|
|
32
31
|
|
|
33
|
-
Then copy its
|
|
32
|
+
Then copy its migrations, of runs, and run them:
|
|
34
33
|
|
|
35
34
|
```bash
|
|
36
35
|
bin/rails drive:install:migrations db:migrate
|
|
@@ -58,29 +57,16 @@ Run.record 'engines' do
|
|
|
58
57
|
end
|
|
59
58
|
```
|
|
60
59
|
|
|
61
|
-
A table written a record at a time
|
|
60
|
+
A table written a record at a time counts each save and delete in the run at hand:
|
|
62
61
|
|
|
63
62
|
```ruby
|
|
64
63
|
class Publication < ApplicationRecord
|
|
65
|
-
include
|
|
66
|
-
|
|
67
|
-
KEY = 'document_number'.freeze
|
|
68
|
-
SOURCE = 'federal_register'.freeze
|
|
64
|
+
include Counted
|
|
69
65
|
end
|
|
70
66
|
```
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
```ruby
|
|
75
|
-
resource :feed, only: :show
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Where `FEED_TOKEN` is set, it asks for that token (`Authorization: Bearer …`). Runs commit one
|
|
79
|
-
at a time, so a reader that remembers the last id it applied never misses a change.
|
|
80
|
-
|
|
81
|
-
The log is meant to hold about a month: call `Change.prune` after each night's refresh, and it
|
|
82
|
-
deletes the changes of runs that started over 30 days ago. A reader that falls further behind
|
|
83
|
-
copies the tables afresh.
|
|
68
|
+
Runs commit one at a time, so another app reading the tables directly sees either the last
|
|
69
|
+
run's rows or the one before's, never a mix.
|
|
84
70
|
|
|
85
71
|
In tests, `Drive::TestHelper#stage` stages rows as a source's file would:
|
|
86
72
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# A table written a record at a time, each save and delete counted in the current run.
|
|
2
|
+
module Counted
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
after_save :count_upsert, if: :saved_changes?
|
|
7
|
+
after_destroy :count_delete
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def count_upsert
|
|
13
|
+
Run.current.tally(**(previously_new_record? ? { inserted: 1 } : { updated: 1 }))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def count_delete = Run.current.tally(deleted: 1)
|
|
17
|
+
end
|
|
@@ -17,9 +17,7 @@ module Mirrored
|
|
|
17
17
|
# @return [Hash{Symbol => Integer}] how many rows were inserted, updated and deleted.
|
|
18
18
|
def mirror(staged, source:)
|
|
19
19
|
stage_incoming staged
|
|
20
|
-
counts = {
|
|
21
|
-
inserted: log_inserted(source), updated: log_updated(source), deleted: log_deleted(source),
|
|
22
|
-
}
|
|
20
|
+
counts = { inserted: inserted(source), updated: updated(source), deleted: deleted(source) }
|
|
23
21
|
apply source
|
|
24
22
|
counts.tap { Run.current.tally(**it) }
|
|
25
23
|
end
|
|
@@ -47,37 +45,30 @@ module Mirrored
|
|
|
47
45
|
connection.execute 'ANALYZE mirror_incoming'
|
|
48
46
|
end
|
|
49
47
|
|
|
50
|
-
def
|
|
51
|
-
|
|
48
|
+
def inserted(source)
|
|
49
|
+
rows <<~SQL.squish
|
|
52
50
|
FROM mirror_incoming i
|
|
53
51
|
WHERE NOT EXISTS (SELECT 1 FROM #{quoted_table_name} t
|
|
54
52
|
WHERE t.source = #{connection.quote source} AND t.row_key = i.row_key)
|
|
55
53
|
SQL
|
|
56
54
|
end
|
|
57
55
|
|
|
58
|
-
def
|
|
59
|
-
|
|
56
|
+
def updated(source)
|
|
57
|
+
rows <<~SQL.squish
|
|
60
58
|
FROM mirror_incoming i JOIN #{quoted_table_name} t USING (row_key)
|
|
61
59
|
WHERE t.source = #{connection.quote source} AND t.row_hash <> i.row_hash
|
|
62
60
|
SQL
|
|
63
61
|
end
|
|
64
62
|
|
|
65
|
-
def
|
|
66
|
-
|
|
63
|
+
def deleted(source)
|
|
64
|
+
rows <<~SQL.squish
|
|
67
65
|
FROM #{quoted_table_name} i
|
|
68
66
|
WHERE i.source = #{connection.quote source}
|
|
69
67
|
AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = i.row_key)
|
|
70
68
|
SQL
|
|
71
69
|
end
|
|
72
70
|
|
|
73
|
-
def
|
|
74
|
-
connection.update <<~SQL.squish
|
|
75
|
-
INSERT INTO changes (operation, kind, source, row_key, run_id, record, created_at)
|
|
76
|
-
SELECT #{operation}, #{connection.quote table_name}, #{connection.quote source}, i.row_key,
|
|
77
|
-
#{Run.current.id}, #{record}, now()
|
|
78
|
-
#{from}
|
|
79
|
-
SQL
|
|
80
|
-
end
|
|
71
|
+
def rows(from) = connection.select_value("SELECT count(*) #{from}").to_i
|
|
81
72
|
|
|
82
73
|
def apply(source)
|
|
83
74
|
quoted = fields.map { connection.quote_column_name it }
|
data/app/models/crawler.rb
CHANGED
|
@@ -1,33 +1,34 @@
|
|
|
1
|
-
require '
|
|
1
|
+
require 'ipaddr'
|
|
2
|
+
require 'net/http'
|
|
2
3
|
|
|
3
4
|
# A search engine's own crawler, told apart from a client that only names one in its user agent:
|
|
4
|
-
# its address
|
|
5
|
-
# how Google and Bing say to check theirs.
|
|
5
|
+
# its address is in the ranges the search engine publishes for it.
|
|
6
6
|
module Crawler
|
|
7
|
-
# The user agents of
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
# The ranges each search engine publishes, by the user agents of its crawlers.
|
|
8
|
+
RANGES = {
|
|
9
|
+
/Googlebot|Google-InspectionTool|Storebot-Google|GoogleOther/i =>
|
|
10
|
+
'https://developers.google.com/static/crawling/ipranges/common-crawlers.json',
|
|
11
|
+
/bingbot/i => 'https://www.bing.com/toolbox/bingbot.json',
|
|
12
|
+
/Applebot/i => 'https://search.developer.apple.com/applebot.json',
|
|
13
|
+
/Claude-SearchBot/i => 'https://claude.com/crawling/bots.json',
|
|
14
|
+
/OAI-SearchBot/i => 'https://openai.com/searchbot.json',
|
|
15
|
+
/PerplexityBot/i => 'https://www.perplexity.ai/perplexitybot.json',
|
|
16
|
+
}.freeze
|
|
12
17
|
|
|
13
18
|
# @param request [ActionDispatch::Request] request a page was asked with.
|
|
14
|
-
# @return [Boolean] whether it came from a crawler,
|
|
19
|
+
# @return [Boolean] whether it came from a crawler, its ranges fetched once a day.
|
|
15
20
|
def self.verified?(request)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
Rails.cache.fetch(['crawler', ip], expires_in: 1.day) { resolves? ip }
|
|
21
|
+
url = RANGES.find { |agent, _| request.user_agent.to_s.match? agent }&.last
|
|
22
|
+
url.present? && ranges(url).any? { it.include? request.remote_ip }
|
|
19
23
|
end
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
ensure
|
|
30
|
-
dns&.close
|
|
25
|
+
private_class_method def self.ranges(url)
|
|
26
|
+
Rails.cache.fetch(['crawler', url], expires_in: 1.day) do
|
|
27
|
+
uri = URI url
|
|
28
|
+
body = Net::HTTP.start(uri.host, use_ssl: true, open_timeout: 2, read_timeout: 2) do |http|
|
|
29
|
+
http.get(uri.path).body
|
|
30
|
+
end
|
|
31
|
+
JSON.parse(body)['prefixes'].map { IPAddr.new it.values.first }
|
|
32
|
+
end
|
|
31
33
|
end
|
|
32
|
-
private_class_method :resolves?
|
|
33
34
|
end
|
data/app/models/run.rb
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
# One refresh of a source: when it started and finished, and how many rows it changed.
|
|
2
2
|
class Run < ApplicationRecord
|
|
3
|
-
# The lock every refresh takes, so runs commit one at a time
|
|
3
|
+
# The lock every refresh takes, so runs commit one at a time.
|
|
4
4
|
LOCK = 1_729_001
|
|
5
5
|
|
|
6
|
-
# Named apart from Active Record’s own `changes`, which says what an unsaved record changed.
|
|
7
|
-
has_many :row_changes, class_name: 'Change', dependent: :delete_all
|
|
8
|
-
|
|
9
6
|
# Refreshes a source inside one transaction, the only one running, recording what it changed.
|
|
10
7
|
# @param source [String] what is refreshed, such as "registry".
|
|
11
8
|
# @return [Run] finished run.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class DropChanges < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
drop_table :changes do |t|
|
|
4
|
+
t.enum :operation, enum_type: :operation, null: false
|
|
5
|
+
t.text :kind, null: false
|
|
6
|
+
t.text :source, null: false
|
|
7
|
+
t.text :row_key, null: false
|
|
8
|
+
t.references :run, null: false, foreign_key: true
|
|
9
|
+
t.jsonb :record
|
|
10
|
+
t.datetime :created_at, null: false
|
|
11
|
+
t.index %i[kind row_key]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
drop_enum :operation, %w[upsert delete]
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/drive/engine.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Drive
|
|
2
|
-
# Lends the host its models (Run,
|
|
3
|
-
#
|
|
2
|
+
# Lends the host its models (Run, Mirrored, Counted, AccessFile, Crawler), RateLimited and the
|
|
3
|
+
# migrations of runs (`bin/rails drive:install:migrations`).
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
engine_name 'drive'
|
|
6
6
|
end
|
data/lib/drive/version.rb
CHANGED
data/lib/drive.rb
CHANGED
|
@@ -2,6 +2,6 @@ require 'drive/version'
|
|
|
2
2
|
require 'drive/engine'
|
|
3
3
|
|
|
4
4
|
# Keeps a copy of the tables another source publishes, changing only the rows the source did,
|
|
5
|
-
# and
|
|
5
|
+
# and counts what each refresh changed (Mirrored, Counted, Run).
|
|
6
6
|
module Drive
|
|
7
7
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: drive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- claudiob
|
|
@@ -52,8 +52,8 @@ dependencies:
|
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '8.1'
|
|
54
54
|
description: 'A Rails engine for apps that mirror a public source table for table:
|
|
55
|
-
each refresh changes only the rows the source did, in one run, and
|
|
56
|
-
|
|
55
|
+
each refresh changes only the rows the source did, in one run, and counts how many
|
|
56
|
+
it inserted, updated and deleted.'
|
|
57
57
|
email:
|
|
58
58
|
- claudiob@users.noreply.github.com
|
|
59
59
|
executables: []
|
|
@@ -64,15 +64,14 @@ files:
|
|
|
64
64
|
- MIT-LICENSE
|
|
65
65
|
- README.md
|
|
66
66
|
- app/controllers/concerns/rate_limited.rb
|
|
67
|
-
- app/controllers/feeds_controller.rb
|
|
68
67
|
- app/models/access_file.rb
|
|
69
|
-
- app/models/
|
|
70
|
-
- app/models/concerns/logged.rb
|
|
68
|
+
- app/models/concerns/counted.rb
|
|
71
69
|
- app/models/concerns/mirrored.rb
|
|
72
70
|
- app/models/crawler.rb
|
|
73
71
|
- app/models/drive/current.rb
|
|
74
72
|
- app/models/run.rb
|
|
75
73
|
- db/migrate/20261005100000_create_runs_and_changes.rb
|
|
74
|
+
- db/migrate/20261006100000_drop_changes.rb
|
|
76
75
|
- lib/drive.rb
|
|
77
76
|
- lib/drive/engine.rb
|
|
78
77
|
- lib/drive/test_helper.rb
|
|
@@ -100,5 +99,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
100
99
|
requirements: []
|
|
101
100
|
rubygems_version: 4.0.3
|
|
102
101
|
specification_version: 4
|
|
103
|
-
summary: Keep a copy of what a source publishes,
|
|
102
|
+
summary: Keep a copy of what a source publishes, changing only the rows it changed.
|
|
104
103
|
test_files: []
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# The changes after one another app has read, a page at a time, for it to keep its copy current.
|
|
2
|
-
class FeedsController < ApplicationController
|
|
3
|
-
# How many changes a page holds.
|
|
4
|
-
PAGE = 5_000
|
|
5
|
-
|
|
6
|
-
# The fields of a change the feed answers.
|
|
7
|
-
FIELDS = %w[id operation kind source row_key record created_at].freeze
|
|
8
|
-
|
|
9
|
-
before_action :authenticate, if: -> { ENV['FEED_TOKEN'].present? }
|
|
10
|
-
|
|
11
|
-
# Answers the changes after `after`, of one `kind` (table) or all, as JSON.
|
|
12
|
-
def show
|
|
13
|
-
changes = Change.after(params[:after]).limit PAGE
|
|
14
|
-
changes = changes.where(kind: params[:kind]) if params[:kind]
|
|
15
|
-
render json: changes.map { it.attributes.slice(*FIELDS) }
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
private
|
|
19
|
-
|
|
20
|
-
def authenticate
|
|
21
|
-
authenticate_or_request_with_http_token do |token|
|
|
22
|
-
ActiveSupport::SecurityUtils.secure_compare token, ENV.fetch('FEED_TOKEN')
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
data/app/models/change.rb
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# A row a run inserted, changed or deleted, which another app follows by id to keep its copy.
|
|
2
|
-
class Change < ApplicationRecord
|
|
3
|
-
enum :operation, %w[upsert delete].index_by(&:itself), prefix: true
|
|
4
|
-
|
|
5
|
-
belongs_to :run
|
|
6
|
-
|
|
7
|
-
# The changes after one another app has already read, in the order they were made.
|
|
8
|
-
scope :after, ->(id) { where(id: (id.to_i + 1)..).order(:id) }
|
|
9
|
-
|
|
10
|
-
# Deletes the changes of runs that started before a time, so the log holds about a month.
|
|
11
|
-
# @param before [Time] start of the oldest run whose changes are kept.
|
|
12
|
-
# @return [Integer] changes deleted.
|
|
13
|
-
def self.prune(before: 30.days.ago)
|
|
14
|
-
where(run_id: Run.where(started_at: ...before).select(:id)).delete_all
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# @return [String] operation, table and row key, as the changes' pages name a change.
|
|
18
|
-
def to_s = "#{operation} #{kind} #{row_key}"
|
|
19
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# A table written a record at a time, each save and delete logged as a change of the current run.
|
|
2
|
-
module Logged
|
|
3
|
-
extend ActiveSupport::Concern
|
|
4
|
-
|
|
5
|
-
included do
|
|
6
|
-
after_save :log_upsert, if: :saved_changes?
|
|
7
|
-
after_destroy :log_delete
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
def log_upsert
|
|
13
|
-
log :upsert, attributes.except('id', 'created_at', 'updated_at')
|
|
14
|
-
Run.current.tally(**(previously_new_record? ? { inserted: 1 } : { updated: 1 }))
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def log_delete
|
|
18
|
-
log :delete, nil
|
|
19
|
-
Run.current.tally deleted: 1
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def log(operation, record)
|
|
23
|
-
Change.create! operation:, kind: self.class.table_name, source: self.class::SOURCE,
|
|
24
|
-
row_key: self[self.class::KEY], run: Run.current, record:
|
|
25
|
-
end
|
|
26
|
-
end
|