inat-channel 0.8.0.14 → 0.9.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.
@@ -0,0 +1,108 @@
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
+ begin
15
+ id = observation_source[:id]
16
+ url = observation_source[:uri]
17
+ uuid = observation_source[:uuid]
18
+ user = convert_user observation_source[:user]
19
+ taxon = convert_taxon observation_source[:taxon], observation_source[:identifications]
20
+ places = convert_places observation_source[:place_ids]
21
+ datetime = DateTime.parse(observation_source[:time_observed_at] || observation_source[:observed_on_string])
22
+ location = convert_location observation_source[:geojson]
23
+ description = convert_description observation_source[:description]
24
+ place_guess = observation_source[:place_guess]
25
+ rescue => e
26
+ IC::logger.error e.full_message
27
+ IC::logger.info JSON.pretty_generate(observation_source)
28
+ raise e
29
+ end
30
+ Observation::new id: id, url: url, uuid: uuid, user: user, taxon: taxon, places: places, datetime: datetime, location: location, description: description, place_guess: place_guess
31
+ end
32
+
33
+ private
34
+
35
+ def convert_location location_source
36
+ return nil unless location_source
37
+ lat = location_source.dig :coordinates, 1
38
+ lng = location_source.dig :coordinates, 0
39
+ return nil unless lat && lng
40
+ Location::new lat: lat, lng: lng
41
+ end
42
+
43
+ def convert_description description_source
44
+ return nil if description_source.nil? || description_source.empty?
45
+ Description::new value: description_source
46
+ end
47
+
48
+ def convert_user user_source
49
+ User::new id: user_source[:id], login: user_source[:login], name: user_source[:name]
50
+ end
51
+
52
+ def convert_taxon taxon_source, identifications
53
+ id = taxon_source[:id]
54
+ scientific_name = taxon_source[:name]
55
+ common_name = taxon_source[:preferred_common_name]
56
+ source_ancestors = nil
57
+ identifications.each do |ident|
58
+ it = ident[:taxon]
59
+ if it[:id] == id
60
+ source_ancestors = it[:ancestors]
61
+ break
62
+ end
63
+ end
64
+ ancestors = if source_ancestors
65
+ source_ancestors.map do |anc|
66
+ Ancestor::new id: anc[:id], scientific_name: anc[:name]
67
+ end
68
+ else
69
+ taxon_source[:ancestor_ids].map do |aid|
70
+ Ancestor::new id: aid
71
+ end
72
+ # TODO: load names from API by ancestor_ids
73
+ end
74
+ ancestors << Ancestor::new(id: taxon_source[:id], scientific_name: taxon_source[:name])
75
+ Taxon::new id: id, scientific_name: scientific_name, common_name: common_name, ancestors: ancestors
76
+ end
77
+
78
+ def convert_places place_ids
79
+ places_config = IC::CONFIG[:places]
80
+ return nil unless places_config
81
+ result = []
82
+ places_config.each do |_, items|
83
+ items.each do |item|
84
+ ids = Set[*item[:place_ids]]
85
+ if ids.intersect?(place_ids)
86
+ result << Place::new(text: item[:text], link: item[:link], tag: item[:tag])
87
+ break
88
+ end
89
+ end
90
+ end
91
+ result
92
+ end
93
+
94
+ end
95
+
96
+ end
97
+
98
+ end
99
+
100
+ module IC
101
+
102
+ def convert_observation source
103
+ INatChannel::DataConvert::convert_observation source
104
+ end
105
+
106
+ module_function :convert_observation
107
+
108
+ end
@@ -0,0 +1,201 @@
1
+ require 'date'
2
+ require 'time'
3
+ require 'sanitize'
4
+
5
+ require_relative 'icons'
6
+
7
+ class Date
8
+ def icon
9
+ IC::ICONS[:calendar]
10
+ end
11
+ end
12
+
13
+ class Time
14
+ def icon
15
+ IC::clock_icon self
16
+ end
17
+ end
18
+
19
+ class DateTime
20
+ def icon
21
+ IC::ICONS[:calendar]
22
+ end
23
+ end
24
+
25
+ class String
26
+ def to_tag
27
+ "\##{self.gsub(/\s+/, '_').gsub(/-/, '_').gsub(/[^a-zA-Zа-яА-ЯёЁ_]/, '')}"
28
+ end
29
+ def limit len
30
+ return self if length <= len
31
+
32
+ short = self[0, len]
33
+ last_space = short.rindex(/\s/)
34
+ last_sign = short.rindex(/[,.;:!?]/)
35
+ if last_space
36
+ if last_sign && last_sign + 1 > last_space
37
+ return short[0, last_sign + 1] + '...'
38
+ end
39
+ return short[0, last_space] + '...'
40
+ else
41
+ if last_sign
42
+ return short[0, last_sign + 1] + '...'
43
+ end
44
+ return short + '...'
45
+ end
46
+ end
47
+ end
48
+
49
+ class Array
50
+ def to_tags
51
+ self.map do |item|
52
+ if item.respond_to?(:to_tag)
53
+ item.to_tag
54
+ else
55
+ item&.to_s.to_tag
56
+ end
57
+ end.compact
58
+ end
59
+ end
60
+
61
+ Observation = Data::define :taxon, :id, :uuid, :url, :user, :datetime, :places, :place_guess, :description, :location do
62
+ def icon
63
+ IC::ICONS[:observation]
64
+ end
65
+ def date
66
+ datetime.to_date
67
+ end
68
+ def time
69
+ datetime.to_time
70
+ end
71
+ end
72
+
73
+ Taxon = Data::define :scientific_name, :common_name, :id, :ancestors do
74
+ def icon
75
+ IC::ancestors_icon ancestors.map(&:id)
76
+ end
77
+ def title
78
+ if common_name && !common_name.empty?
79
+ "<b>#{common_name}</b> <i>(#{scientific_name})</i>"
80
+ else
81
+ "<b><i>#{scientific_name}</i></b>"
82
+ end
83
+ end
84
+ def url
85
+ "https://www.inaturalist.org/taxa/#{id}"
86
+ end
87
+ def to_tags
88
+ ancestors.to_tags
89
+ end
90
+ end
91
+
92
+ Ancestor = Data::define :scientific_name, :id do
93
+ def to_tag
94
+ scientific_name.to_tag
95
+ end
96
+ end
97
+
98
+ Place = Data::define :text, :link, :tag do
99
+ def icon
100
+ IC::ICONS[:place]
101
+ end
102
+ def to_tag
103
+ tag&.to_tag
104
+ end
105
+ def title
106
+ text
107
+ end
108
+ def url
109
+ link
110
+ end
111
+ end
112
+
113
+ User = Data::define :id, :login, :name do
114
+ def icon
115
+ IC::ICONS[:user]
116
+ end
117
+ def title
118
+ if name && !name.empty?
119
+ name
120
+ else
121
+ "@#{login}"
122
+ end
123
+ end
124
+ def url
125
+ "https://www.inaturalist.org/people/#{id}"
126
+ end
127
+ end
128
+
129
+ module IC
130
+
131
+ SANITIZE_HTML_CONFIG = {
132
+ elements: [ 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'a', 'code', 'pre', 'tg-spoiler', 'blockquote' ],
133
+ attributes: { 'a' => [ 'href' ] },
134
+ protocols: { 'a' => { 'href' => [ 'http', 'https', 'mailto', 'tg' ] } },
135
+ remove_contents: [ 'script', 'style' ]
136
+ }
137
+
138
+ SANITIZE_TEXT_CONFIG = {
139
+ elements: [],
140
+ remove_contents: [ 'script', 'style' ]
141
+ }
142
+
143
+ end
144
+
145
+ Description = Data::define :value do
146
+ def icon
147
+ IC::ICONS[:description]
148
+ end
149
+ def text
150
+ Sanitize.fragment(value, IC::SANITIZE_TEXT_CONFIG).limit(IC::CONFIG.dig(:tg_bot, :desc_limit))
151
+ end
152
+ def html
153
+ sanitized = Sanitize.fragment value, IC::SANITIZE_HTML_CONFIG
154
+ if sanitized.length > IC::CONFIG.dig(:tg_bot, :desc_limit)
155
+ # В отличие от простого текста, обрезка HTML требует куда более изощренной логики, что неоправданно
156
+ text
157
+ else
158
+ sanitized
159
+ end
160
+ end
161
+ end
162
+
163
+ Location = Data::define :lat, :lng do
164
+ def icon
165
+ IC::ICONS[:location]
166
+ end
167
+ def dms
168
+ lat_dir = lat >= 0 ? "N" : "S"
169
+ lng_dir = lng >= 0 ? "E" : "W"
170
+ lat_abs = lat.abs
171
+ lng_abs = lng.abs
172
+ lat_d = lat_abs.floor
173
+ lat_m = ((lat_abs - lat_d) * 60).floor
174
+ lat_s = ((lat_abs - lat_d - lat_m / 60.0) * 3600).round
175
+ lng_d = lng_abs.floor
176
+ lng_m = ((lng_abs - lng_d) * 60).floor
177
+ lng_s = ((lng_abs - lng_d - lng_m / 60.0) * 3600).round
178
+ "%d°%02d'%02d\"%s %d°%02d'%02d\"%s" % [lat_d, lat_m, lat_s, lat_dir, lng_d, lng_m, lng_s, lng_dir]
179
+ end
180
+ def decimal
181
+ lat_dir = lat >= 0 ? "N" : "S"
182
+ lng_dir = lng >= 0 ? "E" : "W"
183
+ lat_abs = lat.abs
184
+ lng_abs = lng.abs
185
+ "%.4f°%s, %.4f°%s" % [lat_abs, lat_dir, lng_abs, lng_dir]
186
+ end
187
+ def google
188
+ # "https://www.google.com/maps/search/?api=1&query=#{lat},#{lng}&z=#{FORMATS[:zoom]}&ll=#{lat},#{lng}"
189
+ "https://www.google.com/maps/place/#{lat},#{lng}/@#{lat},#{lng},#{IC::CONFIG.dig(:tg_bot, :link_zoom)}z/"
190
+ end
191
+ def yandex
192
+ "https://yandex.ru/maps/?ll=#{lng},#{lat}&z=#{IC::CONFIG.dig(:tg_bot, :link_zoom)}&pt=#{lng},#{lat},pm2rdm1"
193
+ end
194
+ def osm
195
+ "https://www.openstreetmap.org/?mlat=#{lat}&mlon=#{lng}#map=#{IC::CONFIG.dig(:tg_bot, :link_zoom)}/#{lat}/#{lng}"
196
+ end
197
+ def url
198
+ osm
199
+ end
200
+ end
201
+
@@ -1,58 +1,135 @@
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
- # TODO: add ALL taxa with iNat icons and some other large group
32
- }.freeze
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
- # TODO: add other icons like calendar, place, etc.
43
- }.freeze
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].reverse_each do |ancestor_id|
49
- return TAXA_ICONS[ancestor_id] if TAXA_ICONS[ancestor_id]
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
57
113
 
58
114
  end
115
+
116
+ module IC
117
+
118
+ TAXA_ICONS = INatChannel::Icons::TAXA_ICONS
119
+ ICONS = INatChannel::Icons::ICONS
120
+
121
+ def taxon_icon taxon
122
+ INatChannel::Icons::taxon_icon taxon
123
+ end
124
+
125
+ def ancestors_icon ancestor_ids
126
+ INatChannel::Icons::ancestors_icon ancestor_ids
127
+ end
128
+
129
+ def clock_icon time
130
+ INatChannel::Icons::clock_icon time
131
+ end
132
+
133
+ module_function :taxon_icon, :ancestors_icon, :clock_icon
134
+
135
+ end
@@ -12,13 +12,13 @@ module INatChannel
12
12
  class << self
13
13
 
14
14
  def acquire!
15
- file = INatChannel::CONFIG[:lock_file]
15
+ file = IC::CONFIG.dig(:lock_file, :path)
16
16
  FileUtils.mkdir_p File.dirname(file)
17
17
 
18
18
  if File.exist?(file)
19
19
  data = load_data file
20
20
  if stale?(data)
21
- INatChannel::LOGGER.info "Remove stale lock: #{file}"
21
+ IC::logger.info "Remove stale lock: #{file}"
22
22
  File.delete file
23
23
  else
24
24
  raise "Another instance is already running (PID: #{data[:pid]})"
@@ -30,15 +30,15 @@ module INatChannel
30
30
  started_at: Time.now.utc.iso8601
31
31
  }
32
32
  File.write file, JSON.pretty_generate(data)
33
- INatChannel::LOGGER.info "Lock acquired: #{file}"
33
+ IC::logger.info "Lock acquired: #{file}"
34
34
  end
35
35
 
36
36
  def release!
37
- file = INatChannel::CONFIG[:lock_file]
37
+ file = IC::CONFIG.dig(:lock_file, :path)
38
38
  return nil unless File.exist?(file)
39
39
 
40
40
  File.delete file
41
- INatChannel::LOGGER.info "Lock release: #{file}"
41
+ IC::logger.info "Lock release: #{file}"
42
42
  end
43
43
 
44
44
  private
@@ -49,7 +49,7 @@ module INatChannel
49
49
  {}
50
50
  end
51
51
 
52
- LOCK_TTL = 1800 # 30 min
52
+ LOCK_TTL = 300 # 5 min
53
53
 
54
54
  def stale? data
55
55
  if data[:started_at]
@@ -14,7 +14,7 @@ module INatChannel
14
14
 
15
15
  def get_logger
16
16
  lgr = ::Logger::new $stderr
17
- lgr.level = INatChannel::CONFIG[:log_level]
17
+ lgr.level = IC::CONFIG[:log_level]
18
18
  lgr
19
19
  end
20
20
 
@@ -22,7 +22,15 @@ module INatChannel
22
22
 
23
23
  end
24
24
 
25
- LOGGER = Logger::logger
25
+ end
26
+
27
+ module IC
28
+
29
+ def logger
30
+ INatChannel::Logger::logger
31
+ end
32
+
33
+ module_function :logger
26
34
 
27
35
  end
28
36