inat-get 0.8.0.11

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.
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