inat-channel 0.8.0.14 → 0.8.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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/inat-channel/api.rb +2 -1
- data/lib/inat-channel/config.rb +8 -1
- data/lib/inat-channel/data_convert.rb +92 -0
- data/lib/inat-channel/data_types.rb +229 -0
- data/lib/inat-channel/icons.rb +97 -41
- data/lib/inat-channel/message.rb +8 -115
- data/lib/inat-channel/template.rb +90 -0
- data/lib/inat-channel/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c91fbb67ba53ef0148d0c9074551a3c8591cb05b6d3a578858018459213f8c4
|
|
4
|
+
data.tar.gz: b7cefeea01cf40c26574cee11f8ac62657a57435732ed095b3badeebf5c79709
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc896ae4f4a74f64364d1607aabdf2c6acc3d2a3e86e0d5d82e498f5da491201fba99d4c5f1ab769694365914874d401faa85dae97d951695895adfa074731b8
|
|
7
|
+
data.tar.gz: 67f3314b776a1352a0451570e9d6589abbe9467cfa44b1a124cf4f4d00ed2c7fda8db6060b2e7d2d0021dfde43858abc6484d87569b33b232300932e6f88dc8d
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# iNat Telegram Poster
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
|
-
[](https://badge.fury.io/rb/inat-channel)
|
|
5
5
|
[](https://github.com/inat-get/inat-channel/actions/workflows/ruby.yml)
|
|
6
6
|

|
|
7
7
|
|
data/lib/inat-channel/api.rb
CHANGED
|
@@ -17,7 +17,8 @@ module INatChannel
|
|
|
17
17
|
API_ENDPOINT = 'https://api.inaturalist.org/v2/observations'
|
|
18
18
|
LIST_FIELDS = 'uuid'
|
|
19
19
|
SINGLE_FIELDS = '(id:!t,uuid:!t,uri:!t,geojson:(all:!t),user:(login:!t,name:!t),taxon:(ancestor_ids:!t,preferred_common_name:!t,name:!t),' +
|
|
20
|
-
'place_ids:!t,place_guess:!t,observed_on_string:!t,description:!t,photos:(url:!t),
|
|
20
|
+
'place_ids:!t,place_guess:!t,observed_on_string:!t,description:!t,photos:(url:!t),time_observed_at:!t,' +
|
|
21
|
+
'identifications:(taxon:(ancestors:(name:!t))))'
|
|
21
22
|
|
|
22
23
|
private_constant :PER_PAGE, :PAGE_DELAY, :API_ENDPOINT, :LIST_FIELDS, :SINGLE_FIELDS
|
|
23
24
|
|
data/lib/inat-channel/config.rb
CHANGED
|
@@ -56,7 +56,14 @@ module INatChannel
|
|
|
56
56
|
|
|
57
57
|
def load_config path
|
|
58
58
|
raise "Config file not found: #{path}" unless File.exist?(path)
|
|
59
|
-
YAML.safe_load_file
|
|
59
|
+
cfg = YAML.safe_load_file path, symbolize_names: true
|
|
60
|
+
if String === cfg[:places]
|
|
61
|
+
path = File.expand_path(path)
|
|
62
|
+
places_path = File.expand_path(cfg[:places], File.dirname(path))
|
|
63
|
+
places = YAML.safe_load_file places_path, symbolize_names: true
|
|
64
|
+
cfg[:places] = places
|
|
65
|
+
end
|
|
66
|
+
cfg
|
|
60
67
|
end
|
|
61
68
|
|
|
62
69
|
def load_env
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'time'
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
require_relative 'data_types'
|
|
6
|
+
|
|
7
|
+
module INatChannel
|
|
8
|
+
|
|
9
|
+
module DataConvert
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
|
|
13
|
+
def convert_observation observation_source
|
|
14
|
+
id = observation_source[:id]
|
|
15
|
+
url = observation_source[:uri]
|
|
16
|
+
uuid = observation_source[:uuid]
|
|
17
|
+
user = convert_user observation_source[:user]
|
|
18
|
+
taxon = convert_taxon observation_source[:taxon], observation_source[:identifications]
|
|
19
|
+
places = convert_places observation_source[:place_ids]
|
|
20
|
+
datetime = DateTime.parse observation_source[:time_observed_at]
|
|
21
|
+
location = convert_location observation_source[:geojson]
|
|
22
|
+
description = convert_description observation_source[:description]
|
|
23
|
+
place_guess = observation_source[:place_guess]
|
|
24
|
+
Observation::new id: id, url: url, uuid: uuid, user: user, taxon: taxon, places: places, datetime: datetime, location: location, description: description, place_guess: place_guess
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def convert_location location_source
|
|
30
|
+
return nil unless location_source
|
|
31
|
+
lat = location_source.dig :coordinates, 1
|
|
32
|
+
lng = location_source.dig :coordinates, 0
|
|
33
|
+
return nil unless lat && lng
|
|
34
|
+
Location::new lat: lat, lng: lng
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def convert_description description_source
|
|
38
|
+
return nil if description_source.nil? || description_source.empty?
|
|
39
|
+
Description::new value: description_source
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def convert_user user_source
|
|
43
|
+
User::new id: user_source[:id], login: user_source[:login], name: user_source[:name]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def convert_taxon taxon_source, identifications
|
|
47
|
+
id = taxon_source[:id]
|
|
48
|
+
scientific_name = taxon_source[:name]
|
|
49
|
+
common_name = taxon_source[:preferred_common_name]
|
|
50
|
+
source_ancestors = nil
|
|
51
|
+
identifications.each do |ident|
|
|
52
|
+
it = ident[:taxon]
|
|
53
|
+
if it[:id] == id
|
|
54
|
+
source_ancestors = it[:ancestors]
|
|
55
|
+
break
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
ancestors = if source_ancestors
|
|
59
|
+
source_ancestors.map do |anc|
|
|
60
|
+
Ancestor::new id: anc[:id], scientific_name: anc[:name]
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
taxon_source[:ancestor_ids].map do |aid|
|
|
64
|
+
Ancestor::new id: aid
|
|
65
|
+
end
|
|
66
|
+
# TODO: load names from API by ancestor_ids
|
|
67
|
+
end
|
|
68
|
+
ancestors << Ancestor::new(id: taxon_source[:id], scientific_name: taxon_source[:name])
|
|
69
|
+
Taxon::new id: id, scientific_name: scientific_name, common_name: common_name, ancestors: ancestors
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def convert_places place_ids
|
|
73
|
+
places_config = INatChannel::CONFIG[:places]
|
|
74
|
+
return nil unless places_config
|
|
75
|
+
result = []
|
|
76
|
+
places_config.each do |_, items|
|
|
77
|
+
items.each do |item|
|
|
78
|
+
ids = Set[*item[:place_ids]]
|
|
79
|
+
if ids.intersect?(place_ids)
|
|
80
|
+
result << Place::new(text: item[:text], link: item[:link], tag: item[:tag])
|
|
81
|
+
break
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'time'
|
|
3
|
+
require 'sanitize'
|
|
4
|
+
|
|
5
|
+
require_relative 'icons'
|
|
6
|
+
|
|
7
|
+
module INatChannel
|
|
8
|
+
FORMATS = {
|
|
9
|
+
date: '%Y.%m.%d',
|
|
10
|
+
time: '%H:%M %Z',
|
|
11
|
+
datetime: '%Y.%m.%d %H:%M %Z',
|
|
12
|
+
location: :DMS, # or :decimal
|
|
13
|
+
zoom: 12,
|
|
14
|
+
description_limit: 512
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Date
|
|
19
|
+
def icon
|
|
20
|
+
INatChannel::Icons::ICONS[:calendar]
|
|
21
|
+
end
|
|
22
|
+
def to_s
|
|
23
|
+
fmt = INatChannel::FORMATS[:date]
|
|
24
|
+
if fmt
|
|
25
|
+
self.strftime fmt
|
|
26
|
+
else
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Time
|
|
33
|
+
def icon
|
|
34
|
+
INatChannel::Icons::clock_icon self
|
|
35
|
+
end
|
|
36
|
+
def to_s
|
|
37
|
+
fmt = INatChannel::FORMATS[:time]
|
|
38
|
+
if fmt
|
|
39
|
+
self.strftime fmt
|
|
40
|
+
else
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class DateTime
|
|
47
|
+
def icon
|
|
48
|
+
INatChannel::Icons::ICONS[:calendar]
|
|
49
|
+
end
|
|
50
|
+
def to_s
|
|
51
|
+
fmt = INatChannel::FORMATS[:datetime]
|
|
52
|
+
if fmt
|
|
53
|
+
self.strftime fmt
|
|
54
|
+
else
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class String
|
|
61
|
+
def to_tag
|
|
62
|
+
"\##{self.gsub(/\s+/, '_').gsub(/-/, '_').gsub(/[^a-zA-Zа-яА-ЯёЁ_]/, '')}"
|
|
63
|
+
end
|
|
64
|
+
def limit len
|
|
65
|
+
return self if length <= len
|
|
66
|
+
|
|
67
|
+
short = self[0, len]
|
|
68
|
+
last_space = short.rindex(/\s/)
|
|
69
|
+
last_sign = short.rindex(/[,.;:!?]/)
|
|
70
|
+
if last_space
|
|
71
|
+
if last_sign && last_sign + 1 > last_space
|
|
72
|
+
return short[0, last_sign + 1] + '...'
|
|
73
|
+
end
|
|
74
|
+
return short[0, last_space] + '...'
|
|
75
|
+
else
|
|
76
|
+
if last_sign
|
|
77
|
+
return short[0, last_sign + 1] + '...'
|
|
78
|
+
end
|
|
79
|
+
return short + '...'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class Array
|
|
85
|
+
def to_tags
|
|
86
|
+
self.map do |item|
|
|
87
|
+
if item.respond_to?(:to_tag)
|
|
88
|
+
item.to_tag
|
|
89
|
+
else
|
|
90
|
+
item&.to_s.to_tag
|
|
91
|
+
end
|
|
92
|
+
end.compact
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
Observation = Struct::new :taxon, :id, :uuid, :url, :user, :datetime, :places, :place_guess, :description, :location, keyword_init: true do
|
|
97
|
+
def icon
|
|
98
|
+
INatChannel::Icons::ICONS[:observation]
|
|
99
|
+
end
|
|
100
|
+
def date
|
|
101
|
+
datetime.to_date
|
|
102
|
+
end
|
|
103
|
+
def time
|
|
104
|
+
datetime.to_time
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Taxon = Struct::new :scientific_name, :common_name, :id, :ancestors, keyword_init: true do
|
|
109
|
+
def icon
|
|
110
|
+
INatChannel::Icons::ancestors_icon ancestors.map(&:id)
|
|
111
|
+
end
|
|
112
|
+
def title
|
|
113
|
+
if common_name && !common_name.empty?
|
|
114
|
+
"<b>#{common_name}</b> <i>(#{scientific_name})</i>"
|
|
115
|
+
else
|
|
116
|
+
"<b><i>#{scientific_name}</i></b>"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
def url
|
|
120
|
+
"https://www.inaturalist.org/taxa/#{id}"
|
|
121
|
+
end
|
|
122
|
+
def to_tags
|
|
123
|
+
ancestors.to_tags
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Ancestor = Struct::new :scientific_name, :id, keyword_init: true do
|
|
128
|
+
def to_tag
|
|
129
|
+
scientific_name.to_tag
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Place = Struct::new :ids, :text, :link, :tag, keyword_init: true do
|
|
134
|
+
def icon
|
|
135
|
+
INatChannel::Icons::ICONS[:place]
|
|
136
|
+
end
|
|
137
|
+
def to_tag
|
|
138
|
+
tag&.to_tag
|
|
139
|
+
end
|
|
140
|
+
def title
|
|
141
|
+
text
|
|
142
|
+
end
|
|
143
|
+
def url
|
|
144
|
+
link
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
User = Struct::new :id, :login, :name, keyword_init: true do
|
|
149
|
+
def icon
|
|
150
|
+
INatChannel::Icons::ICONS[:user]
|
|
151
|
+
end
|
|
152
|
+
def title
|
|
153
|
+
if name && !name.empty?
|
|
154
|
+
name
|
|
155
|
+
else
|
|
156
|
+
"@#{login}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
def url
|
|
160
|
+
"https://www.inaturalist.org/people/#{id}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
SANITIZE_HTML_CONFIG = {
|
|
165
|
+
elements: [ 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'a', 'code', 'pre', 'tg-spoiler', 'blockquote' ],
|
|
166
|
+
attributes: { 'a' => [ 'href' ] },
|
|
167
|
+
protocols: { 'a' => { 'href' => [ 'http', 'https', 'mailto', 'tg' ] } },
|
|
168
|
+
remove_contents: [ 'script', 'style' ]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
SANITIZE_TEXT_CONFIG = {
|
|
172
|
+
elements: [],
|
|
173
|
+
remove_contents: [ 'script', 'style' ]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
Description = Struct::new :value, keyword_init: true do
|
|
177
|
+
def icon
|
|
178
|
+
INatChannel::Icons::ICONS[:description]
|
|
179
|
+
end
|
|
180
|
+
def text
|
|
181
|
+
Sanitize.fragment(value, SANITIZE_TEXT_CONFIG).limit(FORMATS[:description_limit])
|
|
182
|
+
end
|
|
183
|
+
def html
|
|
184
|
+
sanitized = Sanitize.fragment value, SANITIZE_HTML_CONFIG
|
|
185
|
+
if sanitized.length > FORMATS[:description_limit]
|
|
186
|
+
# В отличие от простого текста, обрезка HTML требует куда более изощренной логики, что неоправданно
|
|
187
|
+
text
|
|
188
|
+
else
|
|
189
|
+
sanitized
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
Location = Struct::new :lat, :lng, keyword_init: true do
|
|
195
|
+
def icon
|
|
196
|
+
INatChannel::Icons::ICONS[:location]
|
|
197
|
+
end
|
|
198
|
+
def title
|
|
199
|
+
lat_dir = lat >= 0 ? 'N' : 'S'
|
|
200
|
+
lng_dir = lng >= 0 ? 'E' : 'W'
|
|
201
|
+
lat_abs = lat.abs
|
|
202
|
+
lng_abs = lng.abs
|
|
203
|
+
if FORMATS[:location] == :DMS
|
|
204
|
+
lat_d = lat_abs.floor
|
|
205
|
+
lat_m = ((lat_abs - lat_d) * 60).floor
|
|
206
|
+
lat_s = ((lat_abs - lat_d - lat_m / 60.0) * 3600).round
|
|
207
|
+
lng_d = lng_abs.floor
|
|
208
|
+
lng_m = ((lng_abs - lng_d) * 60).floor
|
|
209
|
+
lng_s = ((lng_abs - lng_d - lng_m / 60.0) * 3600).round
|
|
210
|
+
"%d°%02d'%02d\"%s %d°%02d'%02d\"%s" % [ lat_d, lat_m, lat_s, lat_dir, lng_d, lng_m, lng_s, lng_dir ]
|
|
211
|
+
else
|
|
212
|
+
"%.4f°%s, %.4f°%s" % [ lat_abs, lat_dir, lng_abs, lng_dir ]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
def google
|
|
216
|
+
# "https://www.google.com/maps/search/?api=1&query=#{lat},#{lng}&z=#{FORMATS[:zoom]}&ll=#{lat},#{lng}"
|
|
217
|
+
"https://www.google.com/maps/place/#{lat},#{lng}/@#{lat},#{lng},#{FORMATS[:zoom]}z/"
|
|
218
|
+
end
|
|
219
|
+
def yandex
|
|
220
|
+
"https://yandex.ru/maps/?ll=#{lng},#{lat}&z=#{FORMATS[:zoom]}&pt=#{lng},#{lat},pm2rdm1"
|
|
221
|
+
end
|
|
222
|
+
def osm
|
|
223
|
+
"https://www.openstreetmap.org/?mlat=#{lat}&mlon=#{lng}#map=#{FORMATS[:zoom]}/#{lat}/#{lng}"
|
|
224
|
+
end
|
|
225
|
+
def url
|
|
226
|
+
osm
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
data/lib/inat-channel/icons.rb
CHANGED
|
@@ -1,56 +1,112 @@
|
|
|
1
|
-
|
|
2
1
|
module INatChannel
|
|
3
|
-
|
|
4
2
|
module Icons
|
|
5
|
-
|
|
6
3
|
TAXA_ICONS = {
|
|
7
|
-
48460
|
|
8
|
-
47126
|
|
9
|
-
47170
|
|
10
|
-
47686
|
|
11
|
-
151817 =>
|
|
12
|
-
67333
|
|
13
|
-
1
|
|
14
|
-
136329 =>
|
|
15
|
-
47124
|
|
16
|
-
47163
|
|
17
|
-
47178
|
|
18
|
-
196614 =>
|
|
19
|
-
47187
|
|
20
|
-
47158
|
|
21
|
-
47119
|
|
22
|
-
71261
|
|
23
|
-
18874
|
|
24
|
-
48222
|
|
25
|
-
47115
|
|
26
|
-
3
|
|
27
|
-
40151
|
|
28
|
-
26036
|
|
29
|
-
20978
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
4
|
+
48460 => "🧬",
|
|
5
|
+
47126 => "🌿",
|
|
6
|
+
47170 => "🍄",
|
|
7
|
+
47686 => "🦠",
|
|
8
|
+
151817 => "🦠",
|
|
9
|
+
67333 => "🦠",
|
|
10
|
+
1 => "🐾",
|
|
11
|
+
136329 => "🌲",
|
|
12
|
+
47124 => "🌸",
|
|
13
|
+
47163 => "🍃",
|
|
14
|
+
47178 => "🐟",
|
|
15
|
+
196614 => "🦈",
|
|
16
|
+
47187 => "🦀",
|
|
17
|
+
47158 => "🪲",
|
|
18
|
+
47119 => "🕷️",
|
|
19
|
+
71261 => "🦅",
|
|
20
|
+
18874 => "🦜",
|
|
21
|
+
48222 => "🌊",
|
|
22
|
+
47115 => "🐚",
|
|
23
|
+
3 => "🐦",
|
|
24
|
+
40151 => "🦌",
|
|
25
|
+
26036 => "🐍",
|
|
26
|
+
20978 => "🐸",
|
|
27
|
+
|
|
28
|
+
# TODO: add ALL taxa with iNat icons and some other large group
|
|
29
|
+
}
|
|
33
30
|
|
|
34
31
|
ICONS = {
|
|
35
|
-
:user =>
|
|
36
|
-
:place =>
|
|
37
|
-
:calendar =>
|
|
38
|
-
:location =>
|
|
39
|
-
:observation =>
|
|
40
|
-
:description =>
|
|
41
|
-
:default_taxon =>
|
|
42
|
-
|
|
43
|
-
}
|
|
32
|
+
:user => "👤",
|
|
33
|
+
:place => "🗺️",
|
|
34
|
+
:calendar => "📅",
|
|
35
|
+
:location => "📍",
|
|
36
|
+
:observation => "📷",
|
|
37
|
+
:description => "📝",
|
|
38
|
+
:default_taxon => "🧬",
|
|
39
|
+
# TODO: add other icons like calendar, place, etc.
|
|
40
|
+
}
|
|
44
41
|
|
|
45
42
|
class << self
|
|
46
|
-
|
|
47
43
|
def taxon_icon taxon
|
|
48
|
-
taxon[:ancestor_ids]
|
|
49
|
-
|
|
44
|
+
ancestors_icon taxon[:ancestor_ids]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ancestors_icon ancestor_ids
|
|
48
|
+
ancestor_ids.reverse_each do |ancestor_id|
|
|
49
|
+
return TAXA_ICONS[ancestor_id] if TAXA_ICONS[ancestor_id]
|
|
50
50
|
end
|
|
51
51
|
return ICONS[:default_taxon]
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
def clock_icon time
|
|
55
|
+
hour = time.hour % 12
|
|
56
|
+
minute = time.min
|
|
57
|
+
|
|
58
|
+
if minute <= 20
|
|
59
|
+
# ≤20 мин - текущий час
|
|
60
|
+
case hour
|
|
61
|
+
when 0, 12 then "🕛"
|
|
62
|
+
when 1 then "🕐"
|
|
63
|
+
when 2 then "🕑"
|
|
64
|
+
when 3 then "🕒"
|
|
65
|
+
when 4 then "🕓"
|
|
66
|
+
when 5 then "🕔"
|
|
67
|
+
when 6 then "🕕"
|
|
68
|
+
when 7 then "🕖"
|
|
69
|
+
when 8 then "🕗"
|
|
70
|
+
when 9 then "🕘"
|
|
71
|
+
when 10 then "🕙"
|
|
72
|
+
when 11 then "🕚"
|
|
73
|
+
end
|
|
74
|
+
elsif minute < 40
|
|
75
|
+
# 21-39 мин - полчаса текущего часа
|
|
76
|
+
case hour
|
|
77
|
+
when 0, 12 then "🕧"
|
|
78
|
+
when 1 then "🕜"
|
|
79
|
+
when 2 then "🕝"
|
|
80
|
+
when 3 then "🕞"
|
|
81
|
+
when 4 then "🕟"
|
|
82
|
+
when 5 then "🕠"
|
|
83
|
+
when 6 then "🕡"
|
|
84
|
+
when 7 then "🕢"
|
|
85
|
+
when 8 then "🕣"
|
|
86
|
+
when 9 then "🕤"
|
|
87
|
+
when 10 then "🕥"
|
|
88
|
+
when 11 then "🕦"
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
# ≥40 мин - следующий час
|
|
92
|
+
next_hour = (hour + 1) % 12
|
|
93
|
+
case next_hour
|
|
94
|
+
when 0, 12 then "🕛"
|
|
95
|
+
when 1 then "🕐"
|
|
96
|
+
when 2 then "🕑"
|
|
97
|
+
when 3 then "🕒"
|
|
98
|
+
when 4 then "🕓"
|
|
99
|
+
when 5 then "🕔"
|
|
100
|
+
when 6 then "🕕"
|
|
101
|
+
when 7 then "🕖"
|
|
102
|
+
when 8 then "🕗"
|
|
103
|
+
when 9 then "🕘"
|
|
104
|
+
when 10 then "🕙"
|
|
105
|
+
when 11 then "🕚"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
54
110
|
end
|
|
55
111
|
|
|
56
112
|
end
|
data/lib/inat-channel/message.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
require 'set'
|
|
2
2
|
require 'sanitize'
|
|
3
|
+
|
|
3
4
|
require_relative 'config'
|
|
4
5
|
require_relative 'icons'
|
|
6
|
+
require_relative 'template'
|
|
5
7
|
|
|
6
8
|
module INatChannel
|
|
7
9
|
|
|
@@ -10,12 +12,12 @@ module INatChannel
|
|
|
10
12
|
class << self
|
|
11
13
|
|
|
12
14
|
def make_message observation
|
|
13
|
-
[
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
template = if INatChannel::CONFIG[:template]
|
|
16
|
+
INatChannel::Template::load INatChannel::CONFIG[:template]
|
|
17
|
+
else
|
|
18
|
+
INatChannel::Template::default
|
|
19
|
+
end
|
|
20
|
+
template.process observation
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def list_photos observation
|
|
@@ -23,115 +25,6 @@ module INatChannel
|
|
|
23
25
|
observation[:photos].map { |ph| ph[:url].gsub("square", "large") }
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
def taxon_title taxon
|
|
29
|
-
icon = INatChannel::Icons::taxon_icon taxon
|
|
30
|
-
link = "https://www.inaturalist.org/taxa/#{taxon[:id]}"
|
|
31
|
-
|
|
32
|
-
common_name = taxon[:preferred_common_name]
|
|
33
|
-
scientific_name = taxon[:name]
|
|
34
|
-
|
|
35
|
-
title = if common_name
|
|
36
|
-
"<b>#{common_name}</b> <i>(#{scientific_name})</i>"
|
|
37
|
-
else
|
|
38
|
-
"<b><i>#{scientific_name}</i></b>"
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
"#{icon} <a href='#{link}'>#{title}</a>"
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def observation_block observation
|
|
45
|
-
user = observation[:user]
|
|
46
|
-
user_title = if user[:name] && !user[:name].empty?
|
|
47
|
-
user[:name]
|
|
48
|
-
else
|
|
49
|
-
"<code>#{user[:login]}</code>"
|
|
50
|
-
end
|
|
51
|
-
user_link = "https://www.inaturalist.org/people/#{user[:login]}"
|
|
52
|
-
observation_part = "#{INatChannel::Icons::ICONS[:observation]} <a href='#{observation[:uri]}'><b>\##{observation[:id]}</b></a>"
|
|
53
|
-
user_part = "#{INatChannel::Icons::ICONS[:user]} <a href='#{user_link}'>#{user_title}</a>"
|
|
54
|
-
date_part = "#{INatChannel::Icons::ICONS[:calendar]} #{observation[:observed_on_string]}"
|
|
55
|
-
description = observation[:description] && Sanitize.fragment(observation[:description])
|
|
56
|
-
description_part = if description && !description.empty?
|
|
57
|
-
"\n#{INatChannel::Icons::ICONS[:description]} #{limit_text(description, 500)}"
|
|
58
|
-
else
|
|
59
|
-
""
|
|
60
|
-
end
|
|
61
|
-
"#{observation_part}\n#{date_part}\n#{user_part}#{description_part}"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def limit_text text, limit
|
|
65
|
-
return text if text.length <= limit
|
|
66
|
-
truncated = text[0, limit]
|
|
67
|
-
last_space = truncated.rindex(/\s/)
|
|
68
|
-
last_sign = truncated.rindex(/[,.;:!?]/)
|
|
69
|
-
if last_space
|
|
70
|
-
if last_sign && last_sign + 1 > last_space
|
|
71
|
-
return truncated[0, last_sign + 1] + '...'
|
|
72
|
-
end
|
|
73
|
-
return truncated[0, last_space] + '...'
|
|
74
|
-
else
|
|
75
|
-
if last_sign
|
|
76
|
-
return truncated[0, last_sign + 1] + '...'
|
|
77
|
-
end
|
|
78
|
-
return truncated + '...'
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def place_block place_ids
|
|
83
|
-
return nil unless CONFIG[:places]
|
|
84
|
-
|
|
85
|
-
place_ids = Set[*place_ids]
|
|
86
|
-
found = []
|
|
87
|
-
CONFIG[:places].each do |_, list|
|
|
88
|
-
list.each do |item|
|
|
89
|
-
item_ids = Set[*item[:place_ids]]
|
|
90
|
-
if place_ids.intersect?(item_ids)
|
|
91
|
-
found << item
|
|
92
|
-
break
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
if found.empty?
|
|
98
|
-
nil
|
|
99
|
-
else
|
|
100
|
-
found.map { |i| "#{INatChannel::Icons::ICONS[:place]} <a href='#{i[:link]}'>#{i[:text]}</a>" }.join("\n")
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def ancestors_block observation
|
|
105
|
-
taxon_id = observation[:taxon][:id]
|
|
106
|
-
ancestors = nil
|
|
107
|
-
observation[:identifications].each do |ident|
|
|
108
|
-
it = ident[:taxon]
|
|
109
|
-
if it[:id] == taxon_id
|
|
110
|
-
ancestors = it[:ancestors]
|
|
111
|
-
break
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
if ancestors
|
|
116
|
-
(ancestors.map { |a| name_to_hashtag(a[:name]) } + [name_to_hashtag(observation[:taxon][:name])]).join(" • ")
|
|
117
|
-
else
|
|
118
|
-
# TODO: load ancestors with new query...
|
|
119
|
-
nil
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def name_to_hashtag name
|
|
124
|
-
"\##{name.gsub(".", "").gsub("-", "").gsub(" ", "_")}"
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def geo_link observation
|
|
128
|
-
return nil unless observation[:geojson]&.[](:coordinates) && observation[:geojson][:type] == "Point"
|
|
129
|
-
|
|
130
|
-
lon, lat = observation[:geojson][:coordinates]
|
|
131
|
-
url = "https://maps.google.com/?q=#{lat},#{lon}"
|
|
132
|
-
"<a href='#{url}'>#{lat.round(4)}°N, #{lon.round(4)}°E</a>"
|
|
133
|
-
end
|
|
134
|
-
|
|
135
28
|
end
|
|
136
29
|
|
|
137
30
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'erb'
|
|
3
|
+
|
|
4
|
+
require_relative 'data_types'
|
|
5
|
+
require_relative 'data_convert'
|
|
6
|
+
|
|
7
|
+
module INatChannel
|
|
8
|
+
|
|
9
|
+
class Template
|
|
10
|
+
|
|
11
|
+
attr_reader :template, :data
|
|
12
|
+
|
|
13
|
+
def initialize template, data
|
|
14
|
+
@template = template
|
|
15
|
+
@data = data
|
|
16
|
+
@renderer = ERB::new @template, trim_mode: '-'
|
|
17
|
+
INatChannel::Icons::TAXA_ICONS.merge! data[:taxa_icons] if data[:taxa_icons]
|
|
18
|
+
INatChannel::Icons::ICONS.merge! data[:icons] if data[:icons]
|
|
19
|
+
INatChannel::FORMATS.merge! data[:formats] if data[:formats]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def process observation_source
|
|
23
|
+
observation = INatChannel::DataConvert::convert_observation observation_source
|
|
24
|
+
vars = {
|
|
25
|
+
observation: observation,
|
|
26
|
+
datetime: observation.datetime,
|
|
27
|
+
location: observation.location,
|
|
28
|
+
places: observation.places,
|
|
29
|
+
taxon: observation.taxon,
|
|
30
|
+
user: observation.user,
|
|
31
|
+
date: observation.date,
|
|
32
|
+
time: observation.time,
|
|
33
|
+
icons: INatChannel::Icons::ICONS,
|
|
34
|
+
taxa_icons: INatChannel::Icons::TAXA_ICONS
|
|
35
|
+
}
|
|
36
|
+
@renderer.result_with_hash vars
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
|
|
41
|
+
def load path
|
|
42
|
+
content = File.read path
|
|
43
|
+
if content.lines(chomp: true).first == '---'
|
|
44
|
+
docs = content.split(/^---\n/m, 3)
|
|
45
|
+
data = if docs[0].strip.empty? && docs[1]
|
|
46
|
+
YAML.safe_load docs[1], symbolize_names: true
|
|
47
|
+
else
|
|
48
|
+
YAML.safe_load docs[0], symbolize_names: true
|
|
49
|
+
end || {}
|
|
50
|
+
template = docs[2] || docs[1] || content
|
|
51
|
+
else
|
|
52
|
+
data = {}
|
|
53
|
+
template = content
|
|
54
|
+
end
|
|
55
|
+
new(template, data)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
DEFAULT_TEMPLATE = <<~ERB
|
|
59
|
+
<%= taxon.icon %> <a href="<%= taxon.url %>"><%= taxon.title %></a>
|
|
60
|
+
|
|
61
|
+
<%= observation.icon %> <a href="<%= observation.url %>">#<%= observation.id %></a>
|
|
62
|
+
<%= datetime.icon %> <%= datetime %>
|
|
63
|
+
<%= user.icon %> <a href="<%= user.url %>"><%= user.title %></a>
|
|
64
|
+
<% if observation.description -%>
|
|
65
|
+
<blockquote><%= observation.description.text %></blockquote>
|
|
66
|
+
<% end -%>
|
|
67
|
+
|
|
68
|
+
<%= location.icon %> <%= location.title %> • <a href="<%= location.google %>">G</a> <a href="<%= location.osm %>">OSM</a>
|
|
69
|
+
<% if places && places.size > 0 -%>
|
|
70
|
+
<% places.each do |place| -%>
|
|
71
|
+
<%= place.icon %> <a href="<%= place.link %>"><%= place.text %></a> <%= '• #' + place.tag if place.tag %>
|
|
72
|
+
<% end -%>
|
|
73
|
+
<% else -%>
|
|
74
|
+
<%= icons[:place] %> <%= observation.place_guess %>
|
|
75
|
+
<% end -%>
|
|
76
|
+
|
|
77
|
+
<%= taxon.to_tags.join(' • ') %>
|
|
78
|
+
ERB
|
|
79
|
+
|
|
80
|
+
def default
|
|
81
|
+
@default ||= new(DEFAULT_TEMPLATE, {}).freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private :new
|
|
85
|
+
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
end
|
data/lib/inat-channel/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: inat-channel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ivan Shikhalev
|
|
@@ -151,11 +151,14 @@ files:
|
|
|
151
151
|
- lib/inat-channel/api.rb
|
|
152
152
|
- lib/inat-channel/config.rb
|
|
153
153
|
- lib/inat-channel/data.rb
|
|
154
|
+
- lib/inat-channel/data_convert.rb
|
|
155
|
+
- lib/inat-channel/data_types.rb
|
|
154
156
|
- lib/inat-channel/icons.rb
|
|
155
157
|
- lib/inat-channel/lock.rb
|
|
156
158
|
- lib/inat-channel/logger.rb
|
|
157
159
|
- lib/inat-channel/message.rb
|
|
158
160
|
- lib/inat-channel/telegram.rb
|
|
161
|
+
- lib/inat-channel/template.rb
|
|
159
162
|
- lib/inat-channel/version.rb
|
|
160
163
|
homepage: https://github.com/inat-get/inat-channel
|
|
161
164
|
licenses:
|