spatial_features 3.10.3 → 3.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e8bae5efe61d81494c5ad4d0f36ec6b60d9787db5bf3cabca56192d67c121fa
4
- data.tar.gz: 5108f1778c2a2692b37a5d8372afc20628a3a9827d8da1c93a2a24eb97e3f9a4
3
+ metadata.gz: 70aa244618e985dd8f41069f861b2356b99f36acf433fdd47dca49a02ffb0f94
4
+ data.tar.gz: 5c70438c1e82fcfa9a4d50d6c29a26dac323f623e45bf843e0fe18636b838c77
5
5
  SHA512:
6
- metadata.gz: e705dc5fb1979bdf3084a36526fd3dfabf9deeb2c5408bd0fc9babc3f2ce0d44a876fd5353b33cb3a7eacc67104d3f02bd63644a0b76ae6dfe8cd6490f7a3eaa
7
- data.tar.gz: 78c2d7749c6c03c79622697c2178128b5a69dedea77a943ee5ccdc4ecf91303ba22e98be6b356a5f870b26371d3460d7b5c6959089260e4da15c4ff9b525211e
6
+ metadata.gz: '0348cdd4d19cc28556df7737f547363184311445c98c20e9c6025977e03be5724cef94c116f96c8ed2744013c6ce9f5b37adab61ba80ac19c38cc0e9917ddcb8'
7
+ data.tar.gz: 6355ba185d8ee20a0f7154c5c9c05e739ef6ad2718b24259c9b7bbf4178f3fc5b63d217817aad4bbca8a0572c5a51c61b576bad24e62fb15a14d0a74d368e378
@@ -6,6 +6,10 @@ module SpatialFeatures
6
6
  extend ActiveSupport::Concern
7
7
  include QueuedSpatialProcessing
8
8
 
9
+ # Read by submitters as often as by staff, so it says what happened rather than which
10
+ # method failed. Any per-file reasons are appended after it.
11
+ EMPTY_IMPORT_MESSAGE = "No mapped areas could be imported.".freeze
12
+
9
13
  included do
10
14
  extend ActiveModel::Callbacks
11
15
  define_model_callbacks :update_features
@@ -49,7 +53,7 @@ module SpatialFeatures
49
53
  store_feature_update_warnings(import_warnings)
50
54
 
51
55
  if imports.present? && features.compact_blank.empty? && !allow_blank
52
- raise EmptyImportError, ["No spatial features were found when updating.", *import_warnings].join(' ')
56
+ raise EmptyImportError, [EMPTY_IMPORT_MESSAGE, *import_warnings].join(' ')
53
57
  end
54
58
  end
55
59
  end
@@ -105,14 +109,30 @@ module SpatialFeatures
105
109
  options = {}
106
110
  end
107
111
 
108
- Array.wrap(send(data_method)).flat_map do |data|
112
+ Array.wrap(send(data_method)).each_with_index.flat_map do |data, index|
109
113
  next unless data.present?
110
114
 
111
- spatial_importer_from_name(importer_name).create_all(data, **options, make_valid: make_valid, tmpdir: tmpdir)
115
+ source_tmpdir = spatial_import_tmpdir(tmpdir, data_method, index)
116
+
117
+ begin
118
+ spatial_importer_from_name(importer_name).create_all(data, **options, make_valid: make_valid, tmpdir: source_tmpdir)
119
+ rescue ImportError, Zip::Error, Errno::ENOENT => e
120
+ # One unreadable file must not discard the geometry of the files uploaded
121
+ # beside it. Stand in for it so the rest of the import proceeds and the
122
+ # reason reaches the user as a warning against that file.
123
+ Importers::UnreadableFile.new(data, e, **options, make_valid: make_valid, tmpdir: source_tmpdir)
124
+ end
112
125
  end
113
126
  end.compact
114
127
  end
115
128
 
129
+ # Every source file unpacks into its own directory. Two KMZs on one record both hold a
130
+ # `doc.kml`, so sharing one tmpdir means the second extraction collides with the first
131
+ # and the whole import dies on an archive that is perfectly fine.
132
+ def spatial_import_tmpdir(tmpdir, data_method, index)
133
+ ::File.join(tmpdir, "#{data_method}-#{index}").tap {|dir| FileUtils.mkdir_p(dir) }
134
+ end
135
+
116
136
  def spatial_importer_from_name(importer_name)
117
137
  "SpatialFeatures::Importers::#{importer_name}".constantize
118
138
  end
@@ -133,7 +153,7 @@ module SpatialFeatures
133
153
  features.delete_all
134
154
  valid, invalid = Feature.defer_aggregate_refresh do
135
155
  Feature.without_caching_derivatives do
136
- imports.flat_map(&:features).partition do |feature|
156
+ imports.flat_map {|import| features_from(import) }.partition do |feature|
137
157
  feature.spatial_model = self
138
158
  if feature.save
139
159
  handle_images(feature)
@@ -167,6 +187,16 @@ module SpatialFeatures
167
187
  valid
168
188
  end
169
189
 
190
+ # Parse failures surface lazily, when an importer's features are first read (e.g. a
191
+ # shapefile archive missing its `.shx`), so they need the same containment as a file
192
+ # that couldn't be opened at all: record the reason against that source and keep going.
193
+ def features_from(import)
194
+ import.features
195
+ rescue ImportError => e
196
+ import.warnings << e.message
197
+ []
198
+ end
199
+
170
200
  def features_cache_key_matches?(cache_key)
171
201
  has_spatial_features_hash? && cache_key == features_hash
172
202
  end
@@ -102,8 +102,10 @@ module SpatialFeatures
102
102
  spatial_processing_jobs('update_features!').where(failed_at: nil, locked_at: nil)
103
103
  end
104
104
 
105
+ # Most recent first: a record that has failed more than once must report why the
106
+ # current attempt failed, not whichever row the database happened to return.
105
107
  def failed_feature_update_jobs
106
- spatial_processing_jobs('update_features!').where.not(failed_at: nil)
108
+ spatial_processing_jobs('update_features!').where.not(failed_at: nil).order(failed_at: :desc)
107
109
  end
108
110
 
109
111
  def spatial_processing_jobs(method_name = nil)
@@ -3,16 +3,27 @@ require 'open-uri'
3
3
  module SpatialFeatures
4
4
  module Importers
5
5
  class File < SimpleDelegator
6
- INVALID_ARCHIVE = "Archive did not contain a .kml, .shp, .json, or .geojson file.".freeze
7
- SUPPORTED_FORMATS = "Supported formats are KMZ, KML, zipped ArcGIS shapefiles, ESRI JSON, and GeoJSON.".freeze
6
+ INVALID_ARCHIVE = "This file doesn't contain any map data.".freeze
7
+ SUPPORTED_FORMATS = "Please upload a KMZ, KML, zipped ArcGIS shapefile, ESRI JSON, or GeoJSON file.".freeze
8
8
 
9
9
  FILE_PATTERNS = [/\.kml$/, /\.shp$/, /\.json$/, /\.geojson$/]
10
10
  def self.create_all(data, **options)
11
11
  Download.open_each(data, unzip: FILE_PATTERNS, downcase: true, tmpdir: options[:tmpdir]).map do |file|
12
12
  new(data, **options, current_file: file)
13
13
  end
14
- rescue Unzip::PathNotFound
15
- raise ImportError, INVALID_ARCHIVE + " " + SUPPORTED_FORMATS
14
+ rescue Unzip::PathNotFound => e
15
+ raise ImportError, invalid_archive_message(e)
16
+ end
17
+
18
+ # Name what the archive held instead of only what we needed — a submitter who is
19
+ # told their upload contains PDFs knows immediately that they attached the wrong
20
+ # file, where "did not contain a .shp" leaves them guessing.
21
+ def self.invalid_archive_message(path_not_found)
22
+ found = path_not_found.extensions
23
+ count = path_not_found.paths.count {|path| !path.end_with?('/') }
24
+ contents = " It contains #{count} #{found.to_sentence} #{'file'.pluralize(count)}." if found.any?
25
+
26
+ [INVALID_ARCHIVE, contents, " ", SUPPORTED_FORMATS].compact.join
16
27
  end
17
28
 
18
29
  # The File importer may be initialized multiple times by `::create_all` if it
@@ -24,8 +35,8 @@ module SpatialFeatures
24
35
  def initialize(data, current_file: nil, **options)
25
36
  begin
26
37
  @current_file = current_file || Download.open_each(data, unzip: FILE_PATTERNS, downcase: true, tmpdir: options[:tmpdir]).first
27
- rescue Unzip::PathNotFound
28
- raise ImportError, INVALID_ARCHIVE
38
+ rescue Unzip::PathNotFound => e
39
+ raise ImportError, self.class.invalid_archive_message(e)
29
40
  end
30
41
 
31
42
  case ::File.extname(data).downcase
@@ -43,14 +54,14 @@ module SpatialFeatures
43
54
  when '.json', '.geojson'
44
55
  __setobj__(ESRIGeoJSON.new(@current_file.path, **options))
45
56
  else
46
- import_error
57
+ import_error!
47
58
  end
48
59
  end
49
60
 
50
61
  private
51
62
 
52
63
  def import_error!
53
- raise ImportError, "Could not import #{filename}. " + SUPPORTED_FORMATS
64
+ raise ImportError, "#{::File.basename(filename)} isn't a file type we can read. " + SUPPORTED_FORMATS
54
65
  end
55
66
 
56
67
  def filename
@@ -47,10 +47,32 @@ module SpatialFeatures
47
47
  doc.remove_namespaces! # We don't care about namespaces since the document is going to be filled with placemark geometry and we want it all without needing to deal with namespaces
48
48
  raise ImportError, "Invalid KML document (root node was '#{doc.root&.name}')" unless doc.root&.name.to_s.casecmp?('kml')
49
49
  discard_network_links(doc)
50
+ discard_overlays(doc)
50
51
  doc
51
52
  end
52
53
  end
53
54
 
55
+ # Overlays drape a georeferenced picture over the map — often a WMS raster exported
56
+ # from a government catalogue — instead of describing an area, so there is no
57
+ # geometry in them to import. A PhotoOverlay also carries a `<Point>` marking where
58
+ # the photo was taken, which is not a footprint either, so the nodes are removed
59
+ # rather than left for `each_record` to pick up. Treated like NetworkLinks: any real
60
+ # geometry in the file still imports, and a file that is nothing but overlays fails
61
+ # with a reason that tells the user what they actually uploaded.
62
+ OVERLAY_ELEMENTS = %w[GroundOverlay PhotoOverlay ScreenOverlay].freeze
63
+
64
+ def discard_overlays(doc)
65
+ overlays = doc.search(*OVERLAY_ELEMENTS)
66
+ return if overlays.empty?
67
+
68
+ names = overlays.map {|overlay| overlay.at_css('name')&.text.presence }.compact.uniq
69
+ described = names.any? ? ": #{names.to_sentence}" : ''
70
+ @warnings << "Skipped #{overlays.size} map #{'image'.pluralize(overlays.size)}#{described}. " \
71
+ "A map image is a picture laid over the map, not a marked area, so there is no boundary to import from it."
72
+
73
+ overlays.remove
74
+ end
75
+
54
76
  # NetworkLinks reference geometry hosted elsewhere (e.g. a remote KMZ) rather
55
77
  # than embedding it, so there is nothing for us to import from them. Rather than
56
78
  # failing the whole file, we drop them and record a warning so any embedded
@@ -63,7 +85,8 @@ module SpatialFeatures
63
85
 
64
86
  names = network_links.map {|link| link.at_css('name')&.text.presence }.compact.uniq
65
87
  described = names.any? ? ": #{names.to_sentence}" : ''
66
- @warnings << "Skipped #{network_links.size} network-linked #{'layer'.pluralize(network_links.size)} that reference remote data and cannot be imported#{described}."
88
+ @warnings << "Skipped #{network_links.size} network-linked #{'layer'.pluralize(network_links.size)}#{described}. " \
89
+ "Network links point at data stored somewhere else rather than holding it, so there is nothing to import from them."
67
90
 
68
91
  network_links.remove
69
92
  end
@@ -21,8 +21,11 @@ module SpatialFeatures
21
21
  Download.open_each(data, unzip: [/\.shp$/], downcase: true).map do |file|
22
22
  new(file, **options)
23
23
  end
24
- rescue Unzip::PathNotFound
25
- raise ImportError, INVALID_ARCHIVE
24
+ rescue Unzip::PathNotFound => e
25
+ # `INVALID_ARCHIVE` lives on `Importers::File` and isn't in this class's constant
26
+ # lookup path, so the bare reference here raised NameError instead of the intended
27
+ # ImportError whenever a model imported with `'Shapefile'` directly.
28
+ raise ImportError, File.invalid_archive_message(e)
26
29
  end
27
30
 
28
31
  private
@@ -36,7 +39,9 @@ module SpatialFeatures
36
39
  rescue Errno::ENOENT => e
37
40
  case e.message
38
41
  when /No such file or directory @ rb_sysopen - (.+)/
39
- raise IncompleteShapefileArchive, "Shapefile archive is missing a required file: #{::File.basename($1)}"
42
+ raise IncompleteShapefileArchive,
43
+ "This shapefile is incomplete — #{::File.basename($1)} is missing. " \
44
+ "A shapefile is a set of files that have to be zipped up together: .shp, .shx, .dbf and .prj."
40
45
  else
41
46
  raise e
42
47
  end
@@ -104,7 +109,7 @@ module SpatialFeatures
104
109
  @possible_shp_files ||= begin
105
110
  Download.open_each(archive, unzip: /\.shp$/, downcase: true)
106
111
  rescue Unzip::PathNotFound
107
- raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a SHP file"
112
+ raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "This archive has no shapefile (.shp) in it."
108
113
  end
109
114
  end
110
115
 
@@ -0,0 +1,47 @@
1
+ require 'digest/md5'
2
+
3
+ module SpatialFeatures
4
+ module Importers
5
+ # Stands in for a source file we couldn't read at all — an archive with no map data in
6
+ # it, a shapefile missing a component, an unsupported format. It behaves like an
7
+ # importer that found nothing and carries the reason as a warning, so the files
8
+ # uploaded beside it still import and the user is told which file was skipped and why.
9
+ #
10
+ # When every source is unreadable the import ends up empty and `update_features!`
11
+ # raises `EmptyImportError` with these warnings as the reason, the same way a file
12
+ # containing only NetworkLinks does.
13
+ class UnreadableFile < Base
14
+ # Fallbacks for failures that didn't come from an importer, whose own messages would
15
+ # mean nothing to the person who uploaded the file — and in the missing-file case
16
+ # would put a server filesystem path in front of them.
17
+ UNREADABLE = "This file couldn't be opened. It may be damaged, or saved in a format we can't read.".freeze
18
+ MISSING = "This file is no longer available on the server. Please upload it again.".freeze
19
+
20
+ def initialize(data, error, **options)
21
+ super(data, **options)
22
+ self.source_identifier ||= ::File.basename(data.to_s)
23
+ @warnings << reason_for(error)
24
+ end
25
+
26
+ # Include the reason so that fixing an importer (or the user re-uploading) produces a
27
+ # different key and the record re-imports rather than matching its cached hash.
28
+ def cache_key
29
+ @cache_key ||= Digest::MD5.hexdigest([@data, *@warnings].join)
30
+ end
31
+
32
+ private
33
+
34
+ def reason_for(error)
35
+ case error
36
+ when ImportError then error.message
37
+ when Errno::ENOENT then MISSING
38
+ else UNREADABLE
39
+ end
40
+ end
41
+
42
+ def each_record
43
+ # Nothing could be read, so there is nothing to yield.
44
+ end
45
+ end
46
+ end
47
+ end
@@ -5,17 +5,52 @@ module SpatialFeatures
5
5
  # paths containing '__macosx' or beginning with a '.'
6
6
  IGNORED_ENTRY_PATHS = /(\A|\/)(__macosx|\.)/i.freeze
7
7
 
8
- def self.paths(file_path, find: nil, **extract_options)
8
+ # Archives that may themselves hold the file we're looking for. A KMZ is a ZIP with a
9
+ # KML inside it, and proponents routinely forward a ZIP of per-layer ZIPs rather than
10
+ # the layers themselves.
11
+ NESTED_ARCHIVE_PATTERN = /\.(zip|kmz)$/i.freeze
12
+
13
+ # How many times we'll unwrap a nested archive before giving up. Two levels covers
14
+ # every case we've seen in practice (a ZIP of shapefile ZIPs, a ZIP holding a KMZ)
15
+ # while bounding the work a deeply nested archive can cost us.
16
+ MAX_NESTING_DEPTH = 2
17
+
18
+ def self.paths(file_path, find: nil, depth: 0, **extract_options)
9
19
  paths = extract(file_path, **extract_options)
10
20
 
11
21
  if find = Array.wrap(find).presence
12
- paths = paths.select {|path| find.any? {|pattern| path.index(pattern) } }
13
- raise(PathNotFound, "Archive did not contain a file matching #{find}") if paths.empty?
22
+ matches = matching_paths(paths, find)
23
+ # Only unwrap nested archives once the archive has yielded nothing usable, so
24
+ # archives that already import keep their current behaviour exactly.
25
+ matches = paths_in_nested_archives(paths, find: find, depth: depth, **extract_options) if matches.empty?
26
+ raise(PathNotFound.new("Archive did not contain a file matching #{find}", paths)) if matches.empty?
27
+ paths = matches
14
28
  end
15
29
 
16
30
  return Array(paths)
17
31
  end
18
32
 
33
+ def self.matching_paths(paths, find)
34
+ paths.select {|path| find.any? {|pattern| path.index(pattern) } }
35
+ end
36
+
37
+ # Unwrap any archives the archive itself contained and search those instead. Each one
38
+ # extracts into its own directory so identically named entries (`doc.kml` in several
39
+ # KMZs) don't overwrite one another, and so a shapefile's sibling components stay
40
+ # beside it for `Validation.validate_shapefile!`.
41
+ def self.paths_in_nested_archives(paths, find:, depth:, tmpdir: nil, **extract_options)
42
+ return [] unless depth < MAX_NESTING_DEPTH
43
+
44
+ paths.grep(NESTED_ARCHIVE_PATTERN).flat_map do |archive|
45
+ begin
46
+ paths(archive, find: find, depth: depth + 1,
47
+ tmpdir: Dir.mktmpdir(nil, File.dirname(archive)), **extract_options)
48
+ rescue PathNotFound, Zip::Error
49
+ [] # This nested archive held nothing we can use; the others may still.
50
+ end
51
+ end
52
+ end
53
+
19
54
  def self.extract(file_path, tmpdir: nil, downcase: false)
20
55
  tmpdir ||= Dir.mktmpdir
21
56
  [].tap do |paths|
@@ -55,6 +90,24 @@ module SpatialFeatures
55
90
 
56
91
  # EXCEPTIONS
57
92
 
58
- class PathNotFound < StandardError; end
93
+ class PathNotFound < StandardError
94
+ # The paths the archive did contain, so callers can tell the user what we found
95
+ # instead of only what we needed.
96
+ attr_reader :paths
97
+
98
+ def initialize(message = nil, paths = [])
99
+ super(message)
100
+ @paths = Array(paths)
101
+ end
102
+
103
+ # Distinct file types in the archive, upcased for display (e.g. `["PDF", "PNG"]`).
104
+ def extensions
105
+ paths.reject {|path| path.end_with?('/') }
106
+ .map {|path| File.extname(path).delete('.').upcase }
107
+ .reject(&:empty?)
108
+ .uniq
109
+ .sort
110
+ end
111
+ end
59
112
  end
60
113
  end
@@ -22,11 +22,18 @@ module SpatialFeatures
22
22
  component_path = "#{path}.#{ext}"
23
23
  next if ::File.file?(component_path) && ::File.readable?(component_path)
24
24
 
25
+ # A shapefile is a set of files that must travel together, and an export that
26
+ # drops one is the single most common thing wrong with an upload. Name the
27
+ # missing part and the way out, rather than only the file we couldn't find.
25
28
  case ext
26
29
  when "prj"
27
- raise ::SpatialFeatures::Importers::IndeterminateShapefileProjection, "Shapefile archive is missing a projection file: #{File.basename(component_path)}"
30
+ raise ::SpatialFeatures::Importers::IndeterminateShapefileProjection,
31
+ "This shapefile has no projection file — #{File.basename(component_path)} is missing, " \
32
+ "so there is no way to tell where on the earth it belongs. Re-export it with the projection included."
28
33
  else
29
- raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a required file: #{File.basename(component_path)}"
34
+ raise ::SpatialFeatures::Importers::IncompleteShapefileArchive,
35
+ "This shapefile is incomplete — #{File.basename(component_path)} is missing. " \
36
+ "A shapefile is a set of files that have to be zipped up together: .shp, .shx, .dbf and .prj."
30
37
  end
31
38
  end
32
39
 
@@ -41,7 +48,7 @@ module SpatialFeatures
41
48
  validate_shapefile!(shp_file, default_proj4_projection: default_proj4_projection)
42
49
  end
43
50
  rescue Unzip::PathNotFound
44
- raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a SHP file" \
51
+ raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "This archive has no shapefile (.shp) in it." \
45
52
  unless allow_generic_zip_files
46
53
  end
47
54
  end
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "3.10.3"
2
+ VERSION = "3.11.0"
3
3
  end
@@ -28,6 +28,7 @@ require 'spatial_features/importers/kml_file'
28
28
  require 'spatial_features/importers/kml_file_arcgis'
29
29
  require 'spatial_features/importers/geomark'
30
30
  require 'spatial_features/importers/shapefile'
31
+ require 'spatial_features/importers/unreadable_file'
31
32
 
32
33
  require 'spatial_features/engine'
33
34
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spatial_features
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.3
4
+ version: 3.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Wallace
@@ -210,6 +210,7 @@ files:
210
210
  - lib/spatial_features/importers/kml_file.rb
211
211
  - lib/spatial_features/importers/kml_file_arcgis.rb
212
212
  - lib/spatial_features/importers/shapefile.rb
213
+ - lib/spatial_features/importers/unreadable_file.rb
213
214
  - lib/spatial_features/uncached_result.rb
214
215
  - lib/spatial_features/unzip.rb
215
216
  - lib/spatial_features/utils.rb