deepl_diff 1.1.1 → 2.1.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/.rubocop.yml +1 -0
- data/README.md +17 -4
- data/deepl_diff.gemspec +3 -6
- data/lib/deepl_diff/cache.rb +6 -4
- data/lib/deepl_diff/chunker.rb +18 -11
- data/lib/deepl_diff/redis_cache_store.rb +10 -5
- data/lib/deepl_diff/redis_rate_limiter.rb +17 -6
- data/lib/deepl_diff/request.rb +37 -6
- data/lib/deepl_diff/tokenizer.rb +2 -1
- data/lib/deepl_diff/version.rb +1 -1
- data/lib/deepl_diff.rb +0 -2
- metadata +1 -97
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c71f2ab36a0f279252d4a42bdd0fc915b51668c87ab05279474598487d19e74
|
|
4
|
+
data.tar.gz: bf24b7de677ac350e1fbe174409442626ff45101a57a04909494e46f36c0a5d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 14e9475cad58ca2114b1849a442b2f01cbb958a2413bd86603c29fddc07cb5d815e8bbb342608b77dc353b1a7899f15b6d447a36a30cd0ef9c591aa726c6dd3d
|
|
7
|
+
data.tar.gz: 3fb543ab7c6cd3111bcd0ddf4843cc1b8d8ec6e4f0f35b18c8c85fda2be590641e2bd2ff3679a3150ca8dd3f36bcd5db47536a1bc1675ec5c6fcc6f7ac84010a
|
data/.rubocop.yml
CHANGED
data/README.md
CHANGED
|
@@ -17,6 +17,16 @@ If your user changes a single word within the long description, you will be char
|
|
|
17
17
|
|
|
18
18
|
Much better approach is to try to translate every repeated structural element (sentence) in your texts array just once to save money. This gem helps to make it done.
|
|
19
19
|
|
|
20
|
+
## Dependencies
|
|
21
|
+
|
|
22
|
+
This gem loads two: [`ox`](https://github.com/ohler55/ox) to walk the HTML, and
|
|
23
|
+
[`punkt-segmenter`](https://github.com/lfcipriani/punkt-segmenter) to split text
|
|
24
|
+
into sentences.
|
|
25
|
+
|
|
26
|
+
Everything else is duck typed and supplied by you: `DeepLDiff.api` is anything
|
|
27
|
+
answering to `#translate`, and both `RedisCacheStore` and `RedisRateLimiter`
|
|
28
|
+
take anything answering to `#with`. Bring your own client, pool and store.
|
|
29
|
+
|
|
20
30
|
## Installation
|
|
21
31
|
|
|
22
32
|
Add this line to your application's Gemfile:
|
|
@@ -38,11 +48,14 @@ Or install it yourself as:
|
|
|
38
48
|
```ruby
|
|
39
49
|
require "deepl_diff"
|
|
40
50
|
|
|
41
|
-
#
|
|
51
|
+
# None of these are dependencies of this gem. It loads only `ox` and
|
|
52
|
+
# `punkt-segmenter`; the API client, the connection pool, and whatever backs
|
|
53
|
+
# the cache and the rate limiter are yours to choose and to require.
|
|
54
|
+
require "deepl"
|
|
42
55
|
require "redis"
|
|
43
56
|
require "connection_pool"
|
|
44
57
|
require "redis-namespace"
|
|
45
|
-
require "ratelimit" # Optional, if you
|
|
58
|
+
require "ratelimit" # Optional, only if you use the rate limiter
|
|
46
59
|
|
|
47
60
|
# Setup https://github.com/wikiti/deepl-rb
|
|
48
61
|
DeepL.configure do |config|
|
|
@@ -108,10 +121,10 @@ DeepL API has a limitation: query can not be longer than approximately 128 KB. I
|
|
|
108
121
|
|
|
109
122
|
## Development
|
|
110
123
|
|
|
111
|
-
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake
|
|
124
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
112
125
|
|
|
113
126
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
114
127
|
|
|
115
128
|
## Contributing
|
|
116
129
|
|
|
117
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
130
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/Halvanhelv/deepl_diff.
|
data/deepl_diff.gemspec
CHANGED
|
@@ -45,12 +45,9 @@ between revisions of long texts.
|
|
|
45
45
|
spec.add_development_dependency "rubocop", "~> 1.90"
|
|
46
46
|
spec.add_development_dependency "simplecov", "~> 1.2"
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
# The only two gems this one loads. Everything else -- the DeepL client, the
|
|
49
|
+
# connection pool, and whatever backs the cache and the rate limiter -- is
|
|
50
|
+
# supplied by the application and duck typed, so it stays out of the gemspec.
|
|
51
51
|
spec.add_dependency "ox", "~> 2.14"
|
|
52
52
|
spec.add_dependency "punkt-segmenter", "~> 0.9"
|
|
53
|
-
spec.add_dependency "ratelimit", "~> 1.1"
|
|
54
|
-
spec.add_dependency "redis", ">= 5.0", "< 7.0"
|
|
55
|
-
spec.add_dependency "redis-namespace", "~> 1.11"
|
|
56
53
|
end
|
data/lib/deepl_diff/cache.rb
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class DeepLDiff::Cache
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
def initialize(from, to)
|
|
5
|
+
@from = from
|
|
6
|
+
@to = to
|
|
7
|
+
end
|
|
8
8
|
|
|
9
9
|
def cached_and_missing(values)
|
|
10
10
|
keys = values.map { |v| key(v) }
|
|
@@ -22,6 +22,8 @@ class DeepLDiff::Cache
|
|
|
22
22
|
|
|
23
23
|
private
|
|
24
24
|
|
|
25
|
+
attr_reader :from, :to
|
|
26
|
+
|
|
25
27
|
def store_value(value, translation)
|
|
26
28
|
cache_store.write(key(value), translation)
|
|
27
29
|
translation
|
data/lib/deepl_diff/chunker.rb
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class DeepLDiff::Chunker
|
|
4
|
-
extend ::Dry::Initializer
|
|
5
|
-
|
|
6
4
|
class Error < StandardError; end
|
|
7
5
|
|
|
8
|
-
Chunk = Struct.new(:texts, :
|
|
6
|
+
Chunk = Struct.new(:texts, :escaped_size)
|
|
9
7
|
|
|
10
8
|
MAX_CHUNK_SIZE = 1700
|
|
11
9
|
COUNT_LIMIT = 300
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
def initialize(values, limit: MAX_CHUNK_SIZE, count_limit: COUNT_LIMIT)
|
|
12
|
+
@values = values
|
|
13
|
+
@limit = limit
|
|
14
|
+
@count_limit = count_limit
|
|
15
|
+
end
|
|
16
16
|
|
|
17
17
|
def call
|
|
18
18
|
chunks.map(&:texts)
|
|
@@ -35,22 +35,29 @@ class DeepLDiff::Chunker
|
|
|
35
35
|
|
|
36
36
|
private
|
|
37
37
|
|
|
38
|
+
attr_reader :values, :limit, :count_limit
|
|
39
|
+
|
|
38
40
|
def next_chunk?(tail, value)
|
|
39
41
|
tail.nil? ||
|
|
40
|
-
(
|
|
41
|
-
tail.texts.size
|
|
42
|
+
(escaped_size(value) + tail.escaped_size > limit) ||
|
|
43
|
+
tail.texts.size >= count_limit
|
|
42
44
|
end
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
# What the limit is about is the size of the request that goes over the
|
|
47
|
+
# wire, so every measurement here is of the escaped form. Mixing it with
|
|
48
|
+
# String#size lets a chunk of non-ASCII text run several times over.
|
|
49
|
+
def escaped_size(text)
|
|
45
50
|
CGI.escape(text).size
|
|
46
51
|
end
|
|
47
52
|
|
|
48
53
|
def update_chunk(chunk, value)
|
|
49
54
|
chunk.texts << value
|
|
50
|
-
chunk.
|
|
55
|
+
chunk.escaped_size += escaped_size(value)
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
def validate_value_size(value)
|
|
54
|
-
|
|
59
|
+
size = escaped_size(value)
|
|
60
|
+
|
|
61
|
+
raise Error, "Too long part #{size} > #{limit}" if size > limit
|
|
55
62
|
end
|
|
56
63
|
end
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class DeepLDiff::RedisCacheStore
|
|
4
|
-
|
|
4
|
+
ONE_WEEK = 60 * 60 * 24 * 7
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
# `connection_pool` is anything answering to #with, and what it yields is
|
|
7
|
+
# anything Redis::Namespace accepts. Neither gem is a dependency of this one.
|
|
8
|
+
def initialize(connection_pool, timeout: ONE_WEEK, namespace: DeepLDiff::CACHE_NAMESPACE)
|
|
9
|
+
@connection_pool = connection_pool
|
|
10
|
+
@timeout = timeout
|
|
11
|
+
@namespace = namespace
|
|
12
|
+
end
|
|
10
13
|
|
|
11
14
|
def read_multi(keys)
|
|
12
15
|
redis { |redis| redis.mget(*keys) }
|
|
@@ -18,6 +21,8 @@ class DeepLDiff::RedisCacheStore
|
|
|
18
21
|
|
|
19
22
|
private
|
|
20
23
|
|
|
24
|
+
attr_reader :connection_pool, :timeout, :namespace
|
|
25
|
+
|
|
21
26
|
def redis
|
|
22
27
|
connection_pool.with do |redis|
|
|
23
28
|
yield Redis::Namespace.new(namespace, redis: redis)
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class DeepLDiff::RedisRateLimiter
|
|
4
|
-
extend Dry::Initializer
|
|
5
|
-
|
|
6
4
|
class RateLimitExceeded < StandardError; end
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
param :interval, default: proc { 60 }
|
|
6
|
+
DEFAULT_THRESHOLD = 8000
|
|
7
|
+
DEFAULT_INTERVAL = 60
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
# `connection_pool` is anything answering to #with, and what it yields is
|
|
10
|
+
# anything Ratelimit accepts. Neither gem is a dependency of this one.
|
|
11
|
+
def initialize(connection_pool,
|
|
12
|
+
threshold: DEFAULT_THRESHOLD,
|
|
13
|
+
interval: DEFAULT_INTERVAL,
|
|
14
|
+
namespace: DeepLDiff::CACHE_NAMESPACE)
|
|
15
|
+
@connection_pool = connection_pool
|
|
16
|
+
@threshold = threshold
|
|
17
|
+
@interval = interval
|
|
18
|
+
@namespace = namespace
|
|
19
|
+
end
|
|
13
20
|
|
|
14
21
|
def check(size)
|
|
15
22
|
connection_pool.with do |redis|
|
|
@@ -19,4 +26,8 @@ class DeepLDiff::RedisRateLimiter
|
|
|
19
26
|
rate_limit.add size
|
|
20
27
|
end
|
|
21
28
|
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :connection_pool, :threshold, :interval, :namespace
|
|
22
33
|
end
|
data/lib/deepl_diff/request.rb
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class DeepLDiff::Request
|
|
4
|
-
extend Dry::Initializer
|
|
5
4
|
extend Forwardable
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
param :options
|
|
6
|
+
class Error < StandardError; end
|
|
9
7
|
|
|
10
8
|
def_delegators :DeepLDiff, :api, :cache_store, :rate_limiter
|
|
11
9
|
def_delegators :"DeepLDiff::Linearizer", :linearize, :restore
|
|
12
10
|
|
|
11
|
+
def initialize(values, options)
|
|
12
|
+
@values = values
|
|
13
|
+
# #from and #to consume their keys so the rest can go to the API as-is.
|
|
14
|
+
# Copy first: the caller's hash is theirs, and it is often frozen.
|
|
15
|
+
@options = options.dup
|
|
16
|
+
end
|
|
17
|
+
|
|
13
18
|
def call
|
|
14
19
|
validate_globals
|
|
15
20
|
|
|
16
|
-
return values if
|
|
21
|
+
return values if same_language? || nothing_to_translate?
|
|
17
22
|
|
|
18
23
|
translation
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
private
|
|
22
27
|
|
|
28
|
+
attr_reader :values, :options
|
|
29
|
+
|
|
23
30
|
def from
|
|
24
31
|
@from ||= options.delete(:from) || detect_language
|
|
25
32
|
end
|
|
@@ -28,6 +35,19 @@ class DeepLDiff::Request
|
|
|
28
35
|
@to ||= options.delete(:to) { nil }
|
|
29
36
|
end
|
|
30
37
|
|
|
38
|
+
# A detected language arrives as a String while :to is usually a Symbol, so
|
|
39
|
+
# the two have to be compared on equal footing or the short circuit never
|
|
40
|
+
# fires and the text gets translated into its own language.
|
|
41
|
+
def same_language?
|
|
42
|
+
!to.nil? && from.to_s.casecmp?(to.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Covers values holding no translatable text at all: "", nil, an empty
|
|
46
|
+
# collection, or a scalar the tokenizer has nothing to say about.
|
|
47
|
+
def nothing_to_translate?
|
|
48
|
+
text_tokens_texts.all?(&:empty?)
|
|
49
|
+
end
|
|
50
|
+
|
|
31
51
|
def detect_language
|
|
32
52
|
api.translate(text_tokens_texts.join(" ")[0..100], nil, to)
|
|
33
53
|
.detected_source_language.downcase
|
|
@@ -123,7 +143,12 @@ class DeepLDiff::Request
|
|
|
123
143
|
# Restores texts from tokens
|
|
124
144
|
# [..., "<b>Horoshiy</b> Malchik", ...]
|
|
125
145
|
def texts_translated
|
|
126
|
-
@texts_translated ||= tokens_translated.map do |group|
|
|
146
|
+
@texts_translated ||= tokens_translated.map.with_index do |group, index|
|
|
147
|
+
source = texts[index]
|
|
148
|
+
# Only strings are rebuilt from tokens. Anything else has no tokens to
|
|
149
|
+
# rebuild from; nil keeps collapsing to "" the way it always has.
|
|
150
|
+
next source unless source.nil? || source.is_a?(String)
|
|
151
|
+
|
|
127
152
|
group.map { |value, type| type == :text ? value : fix_ascii(value) }.join
|
|
128
153
|
end
|
|
129
154
|
end
|
|
@@ -135,7 +160,13 @@ class DeepLDiff::Request
|
|
|
135
160
|
|
|
136
161
|
def call_api(values)
|
|
137
162
|
check_rate_limit(values)
|
|
138
|
-
[api.translate(values, from, to, options)].flatten.map(&:text)
|
|
163
|
+
translations = [api.translate(values, from, to, options)].flatten.map(&:text)
|
|
164
|
+
return translations if translations.size == values.size
|
|
165
|
+
|
|
166
|
+
# Letting a short response through means shifting nils into the results,
|
|
167
|
+
# which surfaces much later as a NoMethodError far from the cause.
|
|
168
|
+
raise Error,
|
|
169
|
+
"API returned #{translations.size} translations for #{values.size} values"
|
|
139
170
|
end
|
|
140
171
|
|
|
141
172
|
def cache
|
data/lib/deepl_diff/tokenizer.rb
CHANGED
|
@@ -149,7 +149,8 @@ class DeepLDiff::Tokenizer < Ox::Sax
|
|
|
149
149
|
|
|
150
150
|
class << self
|
|
151
151
|
def tokenize(value)
|
|
152
|
-
|
|
152
|
+
# Anything that is not a string has no markup and no sentences in it.
|
|
153
|
+
return [] unless value.is_a?(String)
|
|
153
154
|
|
|
154
155
|
tokenizer = new(value).tap do |h|
|
|
155
156
|
Ox.sax_parse(h, StringIO.new(value), HTML_OPTIONS)
|
data/lib/deepl_diff/version.rb
CHANGED
data/lib/deepl_diff.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: deepl_diff
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Islam Gagiev
|
|
@@ -65,54 +65,6 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '1.2'
|
|
68
|
-
- !ruby/object:Gem::Dependency
|
|
69
|
-
name: connection_pool
|
|
70
|
-
requirement: !ruby/object:Gem::Requirement
|
|
71
|
-
requirements:
|
|
72
|
-
- - ">="
|
|
73
|
-
- !ruby/object:Gem::Version
|
|
74
|
-
version: '2.4'
|
|
75
|
-
- - "<"
|
|
76
|
-
- !ruby/object:Gem::Version
|
|
77
|
-
version: '4.0'
|
|
78
|
-
type: :runtime
|
|
79
|
-
prerelease: false
|
|
80
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
81
|
-
requirements:
|
|
82
|
-
- - ">="
|
|
83
|
-
- !ruby/object:Gem::Version
|
|
84
|
-
version: '2.4'
|
|
85
|
-
- - "<"
|
|
86
|
-
- !ruby/object:Gem::Version
|
|
87
|
-
version: '4.0'
|
|
88
|
-
- !ruby/object:Gem::Dependency
|
|
89
|
-
name: deepl-rb
|
|
90
|
-
requirement: !ruby/object:Gem::Requirement
|
|
91
|
-
requirements:
|
|
92
|
-
- - "~>"
|
|
93
|
-
- !ruby/object:Gem::Version
|
|
94
|
-
version: '3.9'
|
|
95
|
-
type: :runtime
|
|
96
|
-
prerelease: false
|
|
97
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
-
requirements:
|
|
99
|
-
- - "~>"
|
|
100
|
-
- !ruby/object:Gem::Version
|
|
101
|
-
version: '3.9'
|
|
102
|
-
- !ruby/object:Gem::Dependency
|
|
103
|
-
name: dry-initializer
|
|
104
|
-
requirement: !ruby/object:Gem::Requirement
|
|
105
|
-
requirements:
|
|
106
|
-
- - "~>"
|
|
107
|
-
- !ruby/object:Gem::Version
|
|
108
|
-
version: '3.2'
|
|
109
|
-
type: :runtime
|
|
110
|
-
prerelease: false
|
|
111
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
112
|
-
requirements:
|
|
113
|
-
- - "~>"
|
|
114
|
-
- !ruby/object:Gem::Version
|
|
115
|
-
version: '3.2'
|
|
116
68
|
- !ruby/object:Gem::Dependency
|
|
117
69
|
name: ox
|
|
118
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -141,54 +93,6 @@ dependencies:
|
|
|
141
93
|
- - "~>"
|
|
142
94
|
- !ruby/object:Gem::Version
|
|
143
95
|
version: '0.9'
|
|
144
|
-
- !ruby/object:Gem::Dependency
|
|
145
|
-
name: ratelimit
|
|
146
|
-
requirement: !ruby/object:Gem::Requirement
|
|
147
|
-
requirements:
|
|
148
|
-
- - "~>"
|
|
149
|
-
- !ruby/object:Gem::Version
|
|
150
|
-
version: '1.1'
|
|
151
|
-
type: :runtime
|
|
152
|
-
prerelease: false
|
|
153
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
154
|
-
requirements:
|
|
155
|
-
- - "~>"
|
|
156
|
-
- !ruby/object:Gem::Version
|
|
157
|
-
version: '1.1'
|
|
158
|
-
- !ruby/object:Gem::Dependency
|
|
159
|
-
name: redis
|
|
160
|
-
requirement: !ruby/object:Gem::Requirement
|
|
161
|
-
requirements:
|
|
162
|
-
- - ">="
|
|
163
|
-
- !ruby/object:Gem::Version
|
|
164
|
-
version: '5.0'
|
|
165
|
-
- - "<"
|
|
166
|
-
- !ruby/object:Gem::Version
|
|
167
|
-
version: '7.0'
|
|
168
|
-
type: :runtime
|
|
169
|
-
prerelease: false
|
|
170
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
171
|
-
requirements:
|
|
172
|
-
- - ">="
|
|
173
|
-
- !ruby/object:Gem::Version
|
|
174
|
-
version: '5.0'
|
|
175
|
-
- - "<"
|
|
176
|
-
- !ruby/object:Gem::Version
|
|
177
|
-
version: '7.0'
|
|
178
|
-
- !ruby/object:Gem::Dependency
|
|
179
|
-
name: redis-namespace
|
|
180
|
-
requirement: !ruby/object:Gem::Requirement
|
|
181
|
-
requirements:
|
|
182
|
-
- - "~>"
|
|
183
|
-
- !ruby/object:Gem::Version
|
|
184
|
-
version: '1.11'
|
|
185
|
-
type: :runtime
|
|
186
|
-
prerelease: false
|
|
187
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
188
|
-
requirements:
|
|
189
|
-
- - "~>"
|
|
190
|
-
- !ruby/object:Gem::Version
|
|
191
|
-
version: '1.11'
|
|
192
96
|
description: "\nDeepL API wrapper for Ruby which helps to translate only changes\nbetween
|
|
193
97
|
revisions of long texts.\n "
|
|
194
98
|
email:
|