inat-get 0.8.0.11

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +674 -0
  3. data/README.md +16 -0
  4. data/Rakefile +4 -0
  5. data/bin/inat-get +59 -0
  6. data/docs/logo.png +0 -0
  7. data/inat-get.gemspec +33 -0
  8. data/lib/extra/enum.rb +184 -0
  9. data/lib/extra/period.rb +252 -0
  10. data/lib/extra/uuid.rb +90 -0
  11. data/lib/inat/app/application.rb +50 -0
  12. data/lib/inat/app/config/messagelevel.rb +22 -0
  13. data/lib/inat/app/config/shiftage.rb +24 -0
  14. data/lib/inat/app/config/updatemode.rb +20 -0
  15. data/lib/inat/app/config.rb +296 -0
  16. data/lib/inat/app/globals.rb +80 -0
  17. data/lib/inat/app/info.rb +21 -0
  18. data/lib/inat/app/logging.rb +35 -0
  19. data/lib/inat/app/preamble.rb +27 -0
  20. data/lib/inat/app/status.rb +74 -0
  21. data/lib/inat/app/task/context.rb +47 -0
  22. data/lib/inat/app/task/dsl.rb +24 -0
  23. data/lib/inat/app/task.rb +75 -0
  24. data/lib/inat/data/api.rb +218 -0
  25. data/lib/inat/data/cache.rb +9 -0
  26. data/lib/inat/data/db.rb +87 -0
  27. data/lib/inat/data/ddl.rb +18 -0
  28. data/lib/inat/data/entity/comment.rb +29 -0
  29. data/lib/inat/data/entity/flag.rb +22 -0
  30. data/lib/inat/data/entity/identification.rb +45 -0
  31. data/lib/inat/data/entity/observation.rb +172 -0
  32. data/lib/inat/data/entity/observationphoto.rb +25 -0
  33. data/lib/inat/data/entity/observationsound.rb +26 -0
  34. data/lib/inat/data/entity/photo.rb +31 -0
  35. data/lib/inat/data/entity/place.rb +57 -0
  36. data/lib/inat/data/entity/project.rb +94 -0
  37. data/lib/inat/data/entity/projectadmin.rb +21 -0
  38. data/lib/inat/data/entity/projectobservationrule.rb +50 -0
  39. data/lib/inat/data/entity/request.rb +58 -0
  40. data/lib/inat/data/entity/sound.rb +27 -0
  41. data/lib/inat/data/entity/taxon.rb +94 -0
  42. data/lib/inat/data/entity/user.rb +67 -0
  43. data/lib/inat/data/entity/vote.rb +22 -0
  44. data/lib/inat/data/entity.rb +291 -0
  45. data/lib/inat/data/enums/conservationstatus.rb +30 -0
  46. data/lib/inat/data/enums/geoprivacy.rb +14 -0
  47. data/lib/inat/data/enums/iconictaxa.rb +23 -0
  48. data/lib/inat/data/enums/identificationcategory.rb +13 -0
  49. data/lib/inat/data/enums/licensecode.rb +16 -0
  50. data/lib/inat/data/enums/projectadminrole.rb +11 -0
  51. data/lib/inat/data/enums/projecttype.rb +37 -0
  52. data/lib/inat/data/enums/qualitygrade.rb +12 -0
  53. data/lib/inat/data/enums/rank.rb +60 -0
  54. data/lib/inat/data/model.rb +551 -0
  55. data/lib/inat/data/query.rb +1145 -0
  56. data/lib/inat/data/sets/dataset.rb +104 -0
  57. data/lib/inat/data/sets/list.rb +190 -0
  58. data/lib/inat/data/sets/listers.rb +15 -0
  59. data/lib/inat/data/sets/wrappers.rb +137 -0
  60. data/lib/inat/data/types/extras.rb +88 -0
  61. data/lib/inat/data/types/location.rb +89 -0
  62. data/lib/inat/data/types/std.rb +293 -0
  63. data/lib/inat/report/table.rb +135 -0
  64. data/lib/inat/utils/deep.rb +30 -0
  65. metadata +137 -0
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'globals'
4
+ require_relative 'task/context'
5
+
6
+ class Task
7
+
8
+ CHECK_LIST = %w[ . .inat .rb ]
9
+ FILE_CHECK_LIST = %w[ .inat .iNat .INat .INAT .rb .RB ]
10
+
11
+ private_constant :CHECK_LIST, :FILE_CHECK_LIST
12
+
13
+ private def existing path
14
+ if File.exist?(path)
15
+ path
16
+ else
17
+ nil
18
+ end
19
+ end
20
+
21
+ private def try_extensions base, *extensions
22
+ FILE_CHECK_LIST.each do |exception|
23
+ path = base + exception
24
+ return path if File.exist?(path)
25
+ end
26
+ return nil
27
+ end
28
+
29
+ private def name_complete? source
30
+ s = source.downcase
31
+ CHECK_LIST.each do |check|
32
+ return true if s.end_with?(check)
33
+ end
34
+ return false
35
+ end
36
+
37
+ # TODO: переписать более внятно
38
+ private def get_names source
39
+ path = File.expand_path source
40
+ basename = File.basename(source, '.*')
41
+ return [ basename, existing(path) ] if name_complete?(source)
42
+ base = path.split('/')[..-2].join('/') + '/' + basename
43
+ name = try_extensions base, *FILE_CHECK_LIST
44
+ return [ basename, name ]
45
+ end
46
+
47
+ def config
48
+ @application.config
49
+ end
50
+
51
+ def logger
52
+ @application.logger
53
+ end
54
+
55
+ def name
56
+ @context&.name
57
+ end
58
+
59
+ def done?
60
+ @context&.done?
61
+ end
62
+
63
+ def initialize application, source
64
+ @application = application
65
+ @basename, @path = get_names source
66
+ raise ArgumentError, "File not found: #{source}!" if @path.nil?
67
+ @context = Task::Context::new self, @basename, @path
68
+ end
69
+
70
+ def execute
71
+ G.current_task = self
72
+ @context.execute
73
+ end
74
+
75
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require 'net/http'
6
+
7
+ require_relative '../app/globals'
8
+ require_relative '../app/status'
9
+
10
+ module API
11
+
12
+ RECORDS_LIMIT = 200
13
+ FREQUENCY_LIMIT = 1.0
14
+
15
+ class << self
16
+
17
+ include LogDSL
18
+
19
+ def init
20
+ @mutex = Mutex::new
21
+ end
22
+
23
+ def get path, part, limit, *ids
24
+ return [] if ids.empty?
25
+ if ids.size > limit
26
+ rest = ids.dup
27
+ head = rest.shift limit
28
+ return get(path, *head) + get(path, *rest)
29
+ end
30
+ result = []
31
+ Status::status '[api]', "#{ path } ..."
32
+ @mutex.synchronize do
33
+ now = Time::new
34
+ if @last_call && now - @last_call < FREQUENCY_LIMIT
35
+ sleep FREQUENCY_LIMIT - (now - @last_call)
36
+ end
37
+ case part
38
+ when :query
39
+ url = G.config[:api][:root] + path.to_s + "?id=#{ ids.join(',') }"
40
+ url += "&per_page=#{ limit }"
41
+ locale = G.config[:api][:locale]
42
+ url += "&locale=#{ locale }" if locale
43
+ preferred_place_id = G.config[:api][:preferred_place_id]
44
+ url += "&preferred_place_id=#{ preferred_place_id }" if preferred_place_id
45
+ when :path
46
+ url = G.config[:api][:root] + path.to_s + "/#{ ids.join(',') }"
47
+ else
48
+ raise ArgumentError, "Invalid 'part' argument: #{ part.inspect }!", caller
49
+ end
50
+ uri = URI(url)
51
+ info "GET: URI = #{ uri.inspect }"
52
+ # Status::status 'GET', uri.to_s
53
+ https = uri.scheme == 'https'
54
+ open_timeout = G.config[:api][:open_timeout]
55
+ read_timeout = G.config[:api][:read_timeout]
56
+ http_options = {
57
+ use_ssl: https
58
+ }
59
+ http_options[:open_timeout] = open_timeout if open_timeout
60
+ http_options[:read_timeout] = read_timeout if read_timeout
61
+ answered = false
62
+ answer_count = 50
63
+ last_time = Time::new
64
+ until answered
65
+ begin
66
+ Net::HTTP::start uri.host, uri.port, **http_options do |http|
67
+ request = Net::HTTP::Get::new uri
68
+ request['User-Agent'] = G.config[:api][:user_agent] || "INat::Get // Unknown Instance"
69
+ response = http.request request
70
+ if Net::HTTPSuccess === response
71
+ data = JSON.parse(response.body)
72
+ result = data['results']
73
+ total = data['total_results']
74
+ paged = data['per_page']
75
+ time_diff = Time::new - last_time
76
+ debug "GET OK: total = #{ total } paged = #{ paged } time = #{ time_diff } "
77
+ # Status::status 'GET', uri.to_s + ' DONE'
78
+ else
79
+ error "Bad response om #{ uri.path }#{ uri.query && !uri.query.empty? && '?' + uri.query || '' }: #{ response.inspect }!"
80
+ result = [ { 'id' => ids.first } ]
81
+ # Status::status 'GET', uri.to_s + ' ERROR'
82
+ end
83
+ end
84
+ answered = true
85
+ rescue Exception
86
+ if answer_count > 0
87
+ answer_count -= 1
88
+ answered = false
89
+ error "Error in HTTP request: #{ $!.inspect }, retry: #{ answer_count }."
90
+ # Status::status "Error in HTTP request: #{ $!.inspect }, retry: #{ answer_count }."
91
+ sleep 2.0
92
+ else
93
+ answered = true
94
+ error "Error in HTTP request: #{ $!.inspect }!"
95
+ # Status::status "Error in HTTP request: #{ $!.inspect }!"
96
+ end
97
+ end
98
+ end
99
+ @last_call = Time::new
100
+ end
101
+ Status::status '[api]', "#{ path } DONE"
102
+ result
103
+ end
104
+
105
+ private def make_url path, **params
106
+ url = G.config[:api][:root] + path.to_s
107
+ query = []
108
+ params.each do |key, value|
109
+ query_param = "#{ key }="
110
+ if Array === value
111
+ query_param += value.map(&:to_query).join(',')
112
+ else
113
+ query_param += value.to_query
114
+ end
115
+ query << query_param
116
+ end
117
+ locale = G.config[:api][:locale]
118
+ query << "locale=#{ locale }" if locale
119
+ preferred_place_id = G.config[:api][:preferred_place_id]
120
+ query << "preferred_place_id=#{ preferred_place_id }" if preferred_place_id
121
+ if !query.empty?
122
+ url += "?" + query.join('&')
123
+ end
124
+ url
125
+ end
126
+
127
+ def query path, first_only: false, **params, &block
128
+ Status::status '[api]', "#{ path } ..."
129
+ para = params.dup
130
+ para.delete_if { |key, _| key.intern == :page }
131
+ para[:per_page] = RECORDS_LIMIT
132
+ para[:order_by] = :id
133
+ para[:order] = :asc
134
+ result = []
135
+ rest = nil
136
+ total = 0
137
+ @mutex.synchronize do
138
+ now = Time::new
139
+ if @last_call && now - @last_call < FREQUENCY_LIMIT
140
+ sleep FREQUENCY_LIMIT - (now - @last_call)
141
+ end
142
+ url = make_url path, **para
143
+ uri = URI(url)
144
+ info "QUERY: URI = #{ uri.inspect }"
145
+ # Status::status 'QUERY', uri.to_s
146
+ https = uri.scheme == 'https'
147
+ open_timeout = G.config[:api][:open_timeout]
148
+ read_timeout = G.config[:api][:read_timeout]
149
+ http_options = {
150
+ use_ssl: https
151
+ }
152
+ http_options[:open_timeout] = open_timeout if open_timeout
153
+ http_options[:read_timeout] = read_timeout if read_timeout
154
+ answered = false
155
+ answer_count = 50
156
+ last_time = Time::new
157
+ until answered
158
+ begin
159
+ Net::HTTP::start uri.host, uri.port, **http_options do |http|
160
+ request = Net::HTTP::Get::new uri
161
+ request["User-Agent"] = G.config[:api][:user_agent] || "INat::Get // Unknown Instance"
162
+ response = http.request request
163
+ if Net::HTTPSuccess === response
164
+ data = JSON.parse(response.body)
165
+ result = data["results"]
166
+ total = data["total_results"]
167
+ paged = data["per_page"]
168
+ time_diff = Time::new - last_time
169
+ debug "QUERY OK: total = #{ total } paged = #{ paged } time = #{ time_diff } "
170
+ if total > paged && !first_only
171
+ max = result.map { |o| o["id"] }.max
172
+ rest = para
173
+ rest[:id_above] = max
174
+ end
175
+ else
176
+ raise RuntimeError, "Invalid response: #{response.inspect}"
177
+ end
178
+ end
179
+ answered = true
180
+ rescue Exception
181
+ if answer_count > 0
182
+ answer_count -= 1
183
+ answered = false
184
+ error "Error in HTTP request: #{ $!.inspect }, retry: #{ answer_count }."
185
+ # Status::status "Error in HTTP request: #{ $!.inspect }, retry: #{ answer_count }."
186
+ sleep 2.0
187
+ else
188
+ raise
189
+ end
190
+ end
191
+ end
192
+ @last_call = Time::new
193
+ end
194
+ Status::status '[api]', "#{ path } DONE"
195
+ # TODO: переделать рекурсию в итерации
196
+ if block_given?
197
+ rr = []
198
+ result.each do |js_object|
199
+ rr << yield(js_object, total)
200
+ end
201
+ rr += query(path, **rest, &block) if rest
202
+ rr
203
+ else
204
+ result += query(path, **rest) if rest
205
+ result
206
+ end
207
+ end
208
+
209
+ def load_file filename
210
+ data = JSON.parse File.read(filename)
211
+ data['results']
212
+ end
213
+
214
+ end
215
+
216
+ end
217
+
218
+ API::init
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # class Cache
4
+
5
+ # class << self
6
+
7
+ # end
8
+
9
+ # end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'sqlite3'
5
+
6
+ require_relative '../app/globals'
7
+ require_relative 'ddl'
8
+
9
+ class DB
10
+
11
+ include LogDSL
12
+
13
+ def self.get_finalizer *dbs
14
+ proc do
15
+ dbs.each { |db| db.close }
16
+ end
17
+ end
18
+
19
+ def initialize
20
+ @mutex = Mutex::new
21
+ @config = G.config
22
+ @directory = @config[:data][:directory]
23
+ FileUtils.mkpath @directory
24
+ @data = SQLite3::Database::open "#{@directory}/inat-cache.db"
25
+ @mutex.synchronize do
26
+ @data.encoding = 'UTF-8'
27
+ @data.auto_vacuum = 1
28
+ @data.results_as_hash = true
29
+ @data.foreign_keys = true
30
+ @data.execute_batch DDL.DDL
31
+ end
32
+ ObjectSpace.define_finalizer self, self.class.get_finalizer(@data)
33
+ end
34
+
35
+ def execute query, *args
36
+ Status::status '[db]', '...'
37
+ result = []
38
+ @mutex.synchronize do
39
+ last_time = Time::new
40
+ info "DB: query = #{ query } args = #{ args.inspect }"
41
+ result = @data.execute query, args
42
+ time_diff = Time::new - last_time
43
+ debug "DB OK: count = #{ Array === result && result.size || 'none' } time = #{ (time_diff * 1000000).to_i }ns"
44
+ end
45
+ Status::status '[db]', 'DONE'
46
+ result
47
+ end
48
+
49
+ def execute_batch query
50
+ Status::status '[db]', '...'
51
+ @mutex.synchronize do
52
+ last_time = Time::new
53
+ info "DB: batch = #{ query }"
54
+ @data.execute_batch "BEGIN TRANSACTION;\n" + query + "\nCOMMIT;\n"
55
+ time_diff = Time::new - last_time
56
+ debug "DB OK: time = #{ (time_diff * 1000000).to_i }ns"
57
+ end
58
+ Status::status '[db]', 'DONE'
59
+ end
60
+
61
+ # def transaction &block
62
+ # raise ArgumentError, "Block is required?", caller unless block_given?
63
+ # @data.transaction(&block)
64
+ # end
65
+
66
+ class << self
67
+
68
+ def instance
69
+ @instance ||= DB::new
70
+ @instance
71
+ end
72
+
73
+ def execute query, *args
74
+ instance.execute query, *args
75
+ end
76
+
77
+ def execute_batch query
78
+ instance.execute_batch query
79
+ end
80
+
81
+ # def transaction &block
82
+ # instance.transaction(&block)
83
+ # end
84
+
85
+ end
86
+
87
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DDL
4
+
5
+ class << self
6
+
7
+ def << model
8
+ @models ||= []
9
+ @models << model
10
+ end
11
+
12
+ def DDL
13
+ @models.map(&:DDL).join("\n")
14
+ end
15
+
16
+ end
17
+
18
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../types/std'
4
+ require_relative '../types/extras'
5
+ require_relative '../entity'
6
+
7
+ autoload :Observation, 'inat/data/entity/observation'
8
+ autoload :User, 'inat/data/entity/user'
9
+ autoload :Flag, 'inat/data/entity/flag'
10
+
11
+ class Comment < Entity
12
+
13
+ table :comments
14
+
15
+ field :observation, type: Observation, index: true
16
+
17
+ field :uuid, type: UUID, unique: true
18
+ field :user, type: User, index: true
19
+ field :created_at, type: Time, required: true
20
+ field :body, type: String
21
+ field :hidden, type: Boolean, index: true
22
+
23
+ links :flags, item_type: Flag, owned: true
24
+
25
+ ignore :moderator_actions # TODO: разобраться
26
+
27
+ ignore :created_at_details
28
+
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../types/std'
4
+ require_relative '../types/extras'
5
+ require_relative '../entity'
6
+
7
+ autoload :Observation, 'inat/data/entity/observation'
8
+ autoload :User, 'inat/data/entity/user'
9
+
10
+ class Flag < Entity
11
+
12
+ table :flags
13
+
14
+ field :flag, type: String, required: true
15
+ field :created_at, type: Time
16
+ field :updated_at, type: Time
17
+ field :user, type: User, index: true, required: true
18
+ field :resolved, type: Boolean, index: true
19
+ field :resolver, type: User, index: true
20
+ field :comment, type: String
21
+
22
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'extra/uuid'
4
+
5
+ require_relative '../types/std'
6
+ require_relative '../types/extras'
7
+ require_relative '../entity'
8
+
9
+ require_relative '../enums/identificationcategory'
10
+
11
+ autoload :Observation, 'inat/data/entity/observation'
12
+ autoload :Taxon, 'inat/data/entity/taxon'
13
+ autoload :Flag, 'inat/data/entity/flag'
14
+
15
+ class Identification < Entity
16
+
17
+ api_path :identifications
18
+ api_part :query
19
+ api_limit 200
20
+
21
+ table :identifications
22
+
23
+ field :observation, type: Observation, index: true
24
+
25
+ field :uuid, type: UUID, unique: true
26
+ field :user, type: User, index: true, required: true
27
+ field :created_at, type: Time, index: true
28
+ field :body, type: String
29
+ field :category, type: IdentificationCategory, index: true
30
+ field :current, type: Boolean
31
+ field :own_observation, type: Boolean, index: true
32
+ field :vision, type: Boolean
33
+ field :disagreement, type: Boolean, index: true
34
+ field :spam, type: Boolean, index: true
35
+ field :hidden, type: Boolean, index: true
36
+ field :taxon, type: Taxon, index: true
37
+ field :previous_observation_taxon, type: Taxon, index: true
38
+
39
+ links :flags, item_type: Flag
40
+
41
+ ignore :taxon_change # TODO: разобраться
42
+ ignore :moderator_actions
43
+ ignore :created_at_details
44
+
45
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../types/std'
4
+ require_relative '../types/extras'
5
+ require_relative '../types/location'
6
+ require_relative '../entity'
7
+ require_relative '../enums/qualitygrade'
8
+ require_relative '../enums/licensecode'
9
+ require_relative '../enums/geoprivacy'
10
+
11
+ autoload :Taxon, 'inat/data/entity/taxon'
12
+ autoload :User, 'inat/data/entity/user'
13
+ autoload :Flag, 'inat/data/entity/flag'
14
+ autoload :ObservationSound, 'inat/data/entity/observationsound'
15
+ autoload :Sound, 'inat/data/entity/sound'
16
+ autoload :ObservationPhoto, 'inat/data/entity/observationphoto'
17
+ autoload :Photo, 'inat/data/entity/photo'
18
+ autoload :Place, 'inat/data/entity/place'
19
+ autoload :Project, 'inat/data/entity/project'
20
+ autoload :Comment, 'inat/data/entity/comment'
21
+ autoload :Identification, 'inat/data/entity/identification'
22
+ autoload :Vote, 'inat/data/entity/vote'
23
+
24
+ class Observation < Entity
25
+
26
+ api_path :observations
27
+ api_part :query
28
+ api_limit 200
29
+
30
+ table :observations
31
+
32
+ field :quality_grade, type: QualityGrade, required: true, index: true
33
+ field :uuid, type: UUID, unique: true
34
+ field :species_guess, type: String
35
+ field :license_code, type: LicenseCode, index: true
36
+ field :description, type: String
37
+ field :captive, type: Boolean, index: true
38
+ field :taxon, type: Taxon, index: true
39
+ field :community_taxon, type: Taxon, index: true
40
+ field :uri, type: URI
41
+ field :obscured, type: Boolean, index: true
42
+ field :spam, type: Boolean, index: true
43
+ field :user, type: User, index: true
44
+ field :mappable, type: Boolean, index: true
45
+
46
+ links :flags, item_type: Flag, owned: true
47
+
48
+ field :time_observed_at, type: Time, index: true
49
+ field :created_at, type: Time, index: true
50
+ field :observed_on, type: Date, index: true
51
+ field :observed_on_string, type: String
52
+ field :updated_at, type: Time, index: true
53
+
54
+ field :geoprivacy, type: GeoPrivacy, index: true
55
+ field :taxon_geoprivacy, type: GeoPrivacy, index: true
56
+ field :location, type: Location, index: true
57
+ field :positional_accuracy, type: Integer, index: true
58
+ field :public_positional_accuracy, type: Integer, index: true
59
+ field :place_guess, type: String
60
+
61
+ backs :observation_sounds, item_type: ObservationSound, owned: true
62
+ links :sounds, item_type: Sound, table_name: :observation_sounds, owned: false
63
+ backs :observation_photos, item_type: ObservationPhoto, owned: true
64
+ links :photos, item_type: Photo, table_name: :observation_photos, owned: false
65
+
66
+ links :places, item_type: Place, owned: true
67
+ links :projects, item_type: Project, owned: true # Это только ручные
68
+ links :cached_projects, item_type: Project, table_name: :project_observations, owned: false
69
+
70
+ field :owners_identification_from_vision, type: Boolean
71
+ field :identifications_most_agree, type: Boolean
72
+ field :identifications_some_agree, type: Boolean
73
+ field :num_identification_agreements, type: Integer
74
+ field :identifications_most_disagree, type: Boolean
75
+ field :num_identification_disagreements, type: Integer
76
+ backs :comments, item_type: Comment, owned: true
77
+ backs :identifications, item_type: Identification, owned: true
78
+
79
+ links :ident_taxa, item_type: Taxon, ids_field: :ident_taxon_ids, index: true, owned: true
80
+
81
+ links :votes, item_type: Vote, owned: true
82
+ links :faves, item_type: Vote, owned: true
83
+
84
+ field :day, type: Integer, index: true
85
+ field :month, type: Integer, index: true
86
+ field :year, type: Integer, index: true
87
+
88
+ block :observed_on_details, type: Object do |value|
89
+ if Hash === value
90
+ self.day = value['day']
91
+ self.month = value['month']
92
+ self.year = value['year']
93
+ end
94
+ end
95
+
96
+ field :endemic, type: Boolean, index: true
97
+ field :introduced, type: Boolean, index: true
98
+ field :native, type: Boolean, index: true
99
+ field :taxon_is_active, type: Boolean, index: true
100
+ field :threatened, type: Boolean, index: true
101
+ field :photo_licensed, type: Boolean, index: true
102
+ field :rank, type: Rank, index: true
103
+
104
+ def post_update
105
+ t = self.taxon
106
+ if t
107
+ self.endemic = t.endemic
108
+ self.introduced = t.introduced
109
+ self.native = t.native
110
+ self.taxon_is_active = t.is_active
111
+ self.threatened = t.threatened
112
+ self.rank = t.rank
113
+ end
114
+ if self.observation_photos != nil
115
+ self.photo_licensed = self.observation_photos.any? { |p| p.photo.license_code != nil }
116
+ end
117
+ end
118
+
119
+ def normalized_taxon ranks
120
+ ranks = (ranks .. ranks) if Rank === ranks
121
+ raise TypeError, "Invalid type for ranks: #{ ranks.inspect }!", caller unless Range === ranks
122
+ min = ranks.begin
123
+ # max = ranks.end
124
+ taxon = self.taxon
125
+ while true do
126
+ return nil if taxon == nil
127
+ return nil if taxon.rank < min
128
+ return taxon if ranks === taxon.rank
129
+ taxon = taxon.parent
130
+ end
131
+ end
132
+
133
+ def sort_key
134
+ time_observed_at || observed_on&.to_time || Time::at(0)
135
+ end
136
+
137
+ ignore :tags # TODO: implement
138
+ ignore :created_time_zone # TODO: подумать...
139
+ ignore :observed_time_zone
140
+ ignore :time_zone_offset
141
+ ignore :geojson
142
+ ignore :annotations
143
+ # ignore :observed_on_details
144
+ ignore :created_at_details
145
+ ignore :cached_votes_total
146
+ ignore :comments_count
147
+ ignore :site_id
148
+ ignore :quality_metrics
149
+ ignore :reviewed_by
150
+ ignore :oauth_application_id
151
+ ignore :project_ids_with_curator_id
152
+ # ignore :place_ids
153
+ ignore :outlinks # TODO
154
+ ignore :faves_count
155
+ ignore :ofvs
156
+ ignore :preferences
157
+ ignore :map_scale
158
+ ignore :identifications_count
159
+ ignore :project_ids_without_curator_id
160
+ ignore :project_observations # TODO: ???
161
+ ignore :non_owner_ids
162
+ ignore :out_of_range
163
+ ignore :id_please
164
+ ignore :context_user_geoprivacy # NEED: обязательно разобраться
165
+ ignore :context_geoprivacy
166
+ ignore :context_taxon_geoprivacy
167
+
168
+ def to_s
169
+ "<a href=\"https://www.inaturalist.org/observations/#{ id }\">\##{ id }</a>"
170
+ end
171
+
172
+ end