Skip to content

Commit c1d3d3d

Browse files
authored
Merge pull request #3739 from MushroomObserver/3427-relax-import-list-limit
3427 relax import list limit
2 parents c506e56 + 2be9712 commit c1d3d3d

File tree

11 files changed

+134
-97
lines changed

11 files changed

+134
-97
lines changed

app/classes/inat/page_parser.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ def next_request(**args)
5858
user_login: @import.inat_username,
5959
# only fungi and slime molds
6060
iconic_taxa: ICONIC_TAXA,
61-
# and which haven't been exported from or inported to MO
61+
# and which haven't been exported from or imported to MO
62+
# This field was written by iNat's defunct Import from MO feature
63+
# is written by Pulk's mirror script, and by
64+
# ObservationImporter#update_mushroom_observer_url_field
6265
without_field: "Mushroom Observer URL"
6366
}.merge(args)
6467
# super_importers can import observations of other users.

app/controllers/inat_imports_controller.rb

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def new
8282
def create
8383
return reload_form unless params_valid?
8484

85+
warn_about_listed_previous_imports
8586
assure_user_has_inat_import_api_key
8687
init_ivars
8788
request_inat_user_authorization
@@ -92,11 +93,22 @@ def create
9293
private
9394

9495
def reload_form
95-
@inat_ids = params[:inat_ids]
96+
# clean trailing commas and whitespace
97+
@inat_ids = params[:inat_ids].sub(/[,\s]+\z/, "")
9698
@inat_username = params[:inat_username]
9799
render(:new)
98100
end
99101

102+
# Were any listed iNat IDs previously imported?
103+
def warn_about_listed_previous_imports
104+
return if importing_all? || !listing_ids?
105+
106+
previous_imports = Observation.where(inat_id: inat_id_list)
107+
return if previous_imports.none?
108+
109+
flash_warning(:inat_previous_import.t(count: previous_imports.count))
110+
end
111+
100112
def assure_user_has_inat_import_api_key
101113
key = APIKey.find_by(user: @user, notes: MO_API_KEY_NOTES)
102114
key = APIKey.create(user: @user, notes: MO_API_KEY_NOTES) if key.nil?
@@ -111,8 +123,8 @@ def init_ivars
111123
importables: importables_count,
112124
imported_count: 0,
113125
avg_import_time: @inat_import.initial_avg_import_seconds,
114-
inat_ids: params[:inat_ids],
115126
inat_username: params[:inat_username].strip,
127+
inat_ids: clean_inat_ids,
116128
response_errors: "",
117129
token: "",
118130
log: [],
@@ -131,6 +143,22 @@ def importables_count
131143
params[:inat_ids].split(",").length
132144
end
133145

146+
def clean_inat_ids
147+
# clean trailing commas and whitespace
148+
inat_ids = params[:inat_ids]&.sub(/[,\s]+\z/, "")
149+
previous_imports = Observation.where(inat_id: inat_id_list)
150+
return inat_ids if previous_imports.none?
151+
152+
# remove previously imported ids
153+
# just in case the iNat user deleted the Mushroom_Observer_URL field
154+
# NOTE: Also useful in manual testing when writes of iNat obss are
155+
# commented out temporarily. jdc 2026-01-15
156+
previous_ids = previous_imports.pluck(:inat_id).map(&:to_s)
157+
remaining_ids =
158+
inat_ids.split(",").map(&:strip).reject { |id| previous_ids.include?(id) }
159+
remaining_ids.join(",")
160+
end
161+
134162
def request_inat_user_authorization
135163
redirect_to(INAT_AUTHORIZATION_URL, allow_other_host: true)
136164
end

app/controllers/inat_imports_controller/validators.rb

Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
module InatImportsController::Validators
44
SITE = "https://www.inaturalist.org"
55

6+
# Maximum size of id list param, based on
7+
# Puma max query string size (1024 * 10)
8+
# MAX_COOKIE_SIZE
9+
# - ~256 to allow for other stuff
10+
MAX_ID_LIST_SIZE =
11+
[1024 * 10, ActionDispatch::Cookies::MAX_COOKIE_SIZE].min - 256
12+
613
private
714

815
def params_valid?
@@ -23,8 +30,6 @@ def imports_valid?
2330
imports_unambiguously_designated? &&
2431
valid_inat_ids_param? &&
2532
list_within_size_limits? &&
26-
fresh_import? &&
27-
unmirrored? &&
2833
not_importing_all_anothers?
2934
end
3035

@@ -58,51 +63,16 @@ def contains_illegal_characters?
5863

5964
def list_within_size_limits?
6065
return true if importing_all? || # ignore list size if importing all
61-
params[:inat_ids].length <= 255
66+
params[:inat_ids].length <= MAX_ID_LIST_SIZE
6267

6368
flash_warning(:inat_too_many_ids_listed.t)
6469
false
6570
end
6671

67-
# Are the listed iNat IDs fresh (i.e., not already imported)?
68-
def fresh_import?
69-
return true if importing_all?
70-
71-
previous_imports = Observation.where(inat_id: inat_id_list)
72-
return true if previous_imports.none?
73-
74-
previous_imports.each do |import|
75-
flash_warning(:inat_previous_import.t(inat_id: import.inat_id,
76-
mo_obs_id: import.id))
77-
end
78-
false
79-
end
80-
8172
def inat_id_list
82-
params[:inat_ids].delete(" ").split(",").map(&:to_i)
83-
end
73+
return [] unless listing_ids?
8474

85-
def unmirrored?
86-
return true if importing_all? # cannot test check this if importing all
87-
88-
conditions = inat_id_list.map do |inat_id|
89-
Observation[:notes].matches("%Mirrored on iNaturalist as <a href=\"https://www.inaturalist.org/observations/#{inat_id}\">%")
90-
end
91-
previously_mirrored = Observation.where(conditions.inject(:or)).to_a
92-
return true if previously_mirrored.blank?
93-
94-
previously_mirrored.each do |obs|
95-
flash_warning(:inat_previous_mirror.t(inat_id: mirrored_inat_id(obs),
96-
mo_obs_id: obs.id))
97-
end
98-
false
99-
end
100-
101-
# When Pulk's `mirror`Python script copies an MO Obs to iNat,
102-
# it adds a link to the iNat obs in the MO Observation notes
103-
def mirrored_inat_id(obs)
104-
match = %r{#{SITE}/observations/(?'inat_id'\d+)}o.match(obs.notes.to_s)
105-
match[:inat_id]
75+
params[:inat_ids].delete(" ").split(",").map(&:to_i)
10676
end
10777

10878
# Block importing **all** of another user's iNat observations

app/jobs/inat_import_job.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,20 @@ def parsing_should_stop?(parsed_page)
109109
end
110110

111111
def import_parsed_page_of_observations(parsed_page)
112-
log("Got parsed page with iNat " \
113-
"#{parsed_page["results"].first["id"]}-" \
114-
"#{parsed_page["results"].last["id"]}")
112+
log_new_page(parsed_page)
115113
inat_import.update(importables: parsed_page["total_results"])
116114
observation_importer.import_page(parsed_page)
117115
log("Finished importing observations on parsed page")
118116
end
119117

118+
def log_new_page(parsed_page)
119+
log("Got parsed page with iNat " \
120+
"#{parsed_page["results"].first["id"]}-" \
121+
"#{parsed_page["results"].last["id"]}")
122+
log("Results on this page: #{parsed_page["results"].size}")
123+
log("Total results: #{parsed_page["total_results"]}")
124+
end
125+
120126
def more_pages?(parsed_page)
121127
parsed_page["total_results"] > parsed_page["page"] * parsed_page["per_page"]
122128
end

app/views/controllers/inat_imports/new.html.erb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
<%= panel.with_heading { :inat_choose_observations.l } %>
1515
<%= panel.with_body do %>
1616
<%= f.label(:inat_id, "#{:inat_import_list.l}: ") %>
17-
<%= f.text_field(:inat_ids, size: 10, value: @inat_ids,
18-
class: "form-control") %>
17+
<%= f.text_area(:inat_ids, value: @inat_ids,
18+
class: "form-control") %>
1919
<%= check_box_with_label(form: f, field: :all,
2020
class: "mt-3", label: :inat_import_all.l) %>
2121
<% end %>

config/locales/en.txt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3098,7 +3098,7 @@
30983098
# observation/inat_imports
30993099
inat_import_create_title: Import from iNat
31003100
inat_choose_observations: "Inat Observations to Import:"
3101-
inat_import_list: "Comma separated list of up to 10 observation #s"
3101+
inat_import_list: "Comma separated list of up to about 384 observation #s"
31023102
inat_import_all: Import all my iNat observations instead of a list
31033103
inat_import_consent: "(Required) I consent to creating MushroomObserver Observation(s) based on my iNaturalist observations, including their photos."
31043104
inat_consent_required: Your consent is required to import Inat observations. Please check the checkbox to give your consent.
@@ -3107,8 +3107,7 @@
31073107
inat_missing_username: Missing your iNat Username
31083108
inat_no_imports_designated: You must list the iNat observation(s) to be imported
31093109
inat_list_xor_all: "Either (a) list the iNat observation(s), or (b) check \"Import all ...\" (not both)"
3110-
inat_previous_import: iNat [inat_id] previously imported as MO Observation [mo_obs_id]
3111-
inat_previous_mirror: iNat [inat_id] is a "mirror" of existing MO Observation [mo_obs_id]
3110+
inat_previous_import: "[count] listed ids previously imported and will not be re-imported"
31123111
inat_too_many_ids_listed: Too many ids listed
31133112
inat_importing_all_anothers: You cannot import all observations of another person
31143113
inat_observed_missing_date: Observed Missing Date

db/cache_schema.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# It's strongly recommended that you check this file into your version control system.
1212

1313
ActiveRecord::Schema[7.2].define(version: 2024_02_03_041048) do
14-
create_table "solid_cache_entries", charset: "utf8mb3", force: :cascade do |t|
14+
create_table "solid_cache_entries", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
1515
t.binary "key", limit: 1024, null: false
1616
t.binary "value", size: :long, null: false
1717
t.datetime "created_at", null: false
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class ChangeInatIdsToText < ActiveRecord::Migration[7.2]
2+
def up
3+
change_column(:inat_imports, :inat_ids, :text)
4+
end
5+
6+
def down
7+
change_column(:inat_imports, :inat_ids, :string)
8+
end
9+
end

db/schema.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[7.2].define(version: 2026_01_01_095531) do
13+
ActiveRecord::Schema[7.2].define(version: 2026_01_14_002432) do
1414
create_table "api_keys", id: :integer, charset: "utf8mb3", force: :cascade do |t|
1515
t.datetime "created_at", precision: nil
1616
t.datetime "last_used", precision: nil
@@ -209,7 +209,7 @@
209209
create_table "inat_imports", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
210210
t.integer "user_id"
211211
t.integer "state", default: 0
212-
t.string "inat_ids"
212+
t.text "inat_ids"
213213
t.datetime "created_at", null: false
214214
t.datetime "updated_at", null: false
215215
t.string "token"

test/controllers/inat_imports_controller_test.rb

Lines changed: 64 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ def test_new_inat_import
3535

3636
assert_response(:success)
3737
assert_form_action(action: :create)
38-
assert_select("input#inat_ids", true,
39-
"Form needs a field for inputting iNat ids")
38+
assert_select("textarea#inat_ids", true,
39+
"Form needs a textarea for inputting iNat ids")
4040
assert_select("input#inat_username", true,
4141
"Form needs a field for inputting iNat username")
4242
assert_select("input[type=checkbox][id=consent]", true,
@@ -152,11 +152,66 @@ def test_create_no_consent
152152
assert_flash_text(:inat_consent_required.l)
153153
end
154154

155+
def test_allows_maximum_ids
156+
user = users(:rolf)
157+
inat_username = "rolf" # use different inat_username to test if it's updated
158+
inat_import = inat_imports(:rolf_inat_import)
159+
assert_equal("Unstarted", inat_import.state,
160+
"Need a Unstarted inat_import fixture")
161+
assert_equal(0, inat_import.total_imported_count.to_i,
162+
"Test needs InatImport fixture without prior imports")
163+
164+
id = 1_234_567_890
165+
reps = InatImportsController::Validators::MAX_ID_LIST_SIZE / id.to_s.length
166+
id_list = (id.to_s * reps).chop
167+
168+
stub_request(:any, authorization_url)
169+
login(user.login)
170+
171+
assert_no_difference(
172+
"Observation.count",
173+
"Authorization request to iNat shouldn't create MO Observation(s)"
174+
) do
175+
post(:create,
176+
params: { inat_ids: id_list, inat_username: inat_username,
177+
consent: 1 })
178+
end
179+
180+
assert_response(:redirect)
181+
assert_equal(id_list, inat_import.reload.inat_ids,
182+
"Failed to save inat_ids at maximum length")
183+
end
184+
185+
def test_strips_trailing_commas_and_space_chars_from_id_list
186+
inat_import = inat_imports(:rolf_inat_import)
187+
user = inat_import.user
188+
assert_equal("Unstarted", inat_import.state,
189+
"Need a Unstarted inat_import fixture")
190+
id_list = "123,456,789, \n"
191+
expected_saved_id_list = "123,456,789"
192+
stub_request(:any, authorization_url)
193+
login(user.login)
194+
195+
post(:create,
196+
params: { inat_ids: id_list,
197+
inat_username: "", # omit this to force form reload
198+
consent: 1 })
199+
200+
assert_form_action(action: :create)
201+
assert_select(
202+
"textarea#inat_ids", { text: expected_saved_id_list, count: 1 },
203+
"inat_ids textarea should have trailing commas and whitespace stripped"
204+
)
205+
end
206+
155207
def test_create_too_many_ids_listed
156208
# generate an id list that's barely too long
157209
id_list = ""
158210
id = 1_234_567_890
159-
id_list += "#{id += 1}," until id_list.length > 255
211+
until id_list.length > InatImportsController::Validators::MAX_ID_LIST_SIZE
212+
id_list += "#{id += 1},"
213+
end
214+
160215
params = { inat_username: "anything", inat_ids: id_list, consent: 1 }
161216

162217
login
@@ -185,44 +240,11 @@ def test_create_previously_imported
185240
post(:create, params: params)
186241
end
187242

188-
# NOTE: 2024-09-04 jdc
189-
# I'd prefer that the flash include links to both obss,
190-
# and that this (or another) assertion check for that.
191-
# At the moment, it's taking too long to figure out how.
192-
assert_flash_text(/iNat #{inat_id} previously imported/)
193-
end
194-
195-
def test_create_previously_mirrored
196-
user = users(:rolf)
197-
inat_id = "1234567"
198-
mirrored_obs = Observation.create(
199-
where: "North Falmouth, Massachusetts, USA",
200-
user: user,
201-
when: "2023-09-08",
202-
inat_id: nil,
203-
# When Pulk's `mirror`Python script copies an MO Obs to iNat,
204-
# it adds a text in this form to the MO Obs notes
205-
# See https://github.com/JacobPulk/mirror
206-
notes: { Other: "Mirrored on iNaturalist as <a href=\"https://www.inaturalist.org/observations/#{inat_id}\">observation #{inat_id}</a> on December 18, 2023" }
207-
)
208-
params = { inat_username: "anything", inat_ids: inat_id, consent: 1 }
209-
210-
login
211-
assert_no_difference(
212-
"Observation.count",
213-
"Imported an iNat obs which had been 'mirrored' from MO"
214-
) do
215-
post(:create, params: params)
216-
end
217-
218-
# NOTE: 2024-09-04 jdc
219-
# I'd prefer that the flash include links to both obss,
220-
# and that this (or another) assertion check for that.
221-
# At the moment, it's taking too long to figure out how.
222-
assert_flash_text(
223-
"iNat #{inat_id} is a &#8220;mirror&#8221; of " \
224-
"existing MO Observation #{mirrored_obs.id}"
225-
)
243+
assert_flash_text(/#{:inat_previous_import.l(count: 1)}/)
244+
# It should continue even if some ids were previously imported
245+
# The job will exclude previous imports via the iNat API
246+
# `without_field: "Mushroom Observer URL"` param.
247+
assert_redirected_to(INAT_AUTHORIZATION_URL)
226248
end
227249

228250
def test_create_strip_inat_username
@@ -280,7 +302,7 @@ def test_create_authorization_request
280302
consent: 1 })
281303
end
282304

283-
assert_response(:redirect)
305+
assert_redirected_to(INAT_AUTHORIZATION_URL)
284306
assert_equal(inat_ids.split(",").length, inat_import.reload.importables,
285307
"Failed to save InatImport.importables")
286308
assert_equal("Authorizing", inat_import.reload.state,

0 commit comments

Comments
 (0)