Skip to content

Commit 51ceb06

Browse files
authored
Merge pull request #741 from Esri/lts.next
[Release] 200.8.1
2 parents 10f2a3c + 16474a4 commit 51ceb06

File tree

201 files changed

+3233
-662
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

201 files changed

+3233
-662
lines changed

.swiftlint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ disabled_rules:
2121
- file_length
2222
- for_where
2323
- force_cast
24+
- force_try
2425
- line_length
25-
- notification_center_detachment
2626
- type_body_length
2727
- type_name
2828

Samples.xcodeproj/project.pbxproj

Lines changed: 128 additions & 9 deletions
Large diffs are not rendered by default.

Scripts/DownloadPortalItemData.swift

Lines changed: 159 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ func uncompressArchive(at sourceURL: URL, to destinationURL: URL) throws {
134134
/// - downloadDirectory: The directory to store the downloaded data in.
135135
/// - Throws: Exceptions when downloading, naming, uncompressing, and moving the file.
136136
/// - Returns: The name of the downloaded file.
137-
func downloadFile(from sourceURL: URL, to downloadDirectory: URL) async throws -> String? {
137+
func downloadFile(from sourceURL: URL, to downloadDirectory: URL) async throws -> String {
138138
let (temporaryURL, response) = try await URLSession.shared.download(from: sourceURL)
139139

140-
guard let suggestedFilename = response.suggestedFilename else { return nil }
140+
guard let suggestedFilename = response.suggestedFilename else { fatalError("No suggested filename from server.") }
141141
let isArchive = NSString(string: suggestedFilename).pathExtension == "zip"
142142

143143
let downloadName: String = try {
@@ -153,7 +153,7 @@ func downloadFile(from sourceURL: URL, to downloadDirectory: URL) async throws -
153153
}
154154
}()
155155
let downloadURL = downloadDirectory.appendingPathComponent(downloadName, isDirectory: false)
156-
156+
// Optionally removes any existing file without causing error.
157157
try? FileManager.default.removeItem(at: downloadURL)
158158

159159
if isArchive {
@@ -173,119 +173,203 @@ func downloadFile(from sourceURL: URL, to downloadDirectory: URL) async throws -
173173

174174
// MARK: Script Entry
175175

176-
let arguments = CommandLine.arguments
177-
178-
guard arguments.count == 3 else {
179-
print("error: Invalid number of arguments.")
180-
exit(1)
181-
}
182-
183-
/// The samples directory, i.e., $SRCROOT/Shared/Samples.
184-
let samplesDirectoryURL = URL(fileURLWithPath: arguments[1], isDirectory: true)
185-
/// The download directory, i.e., $SRCROOT/Portal Data.
186-
let downloadDirectoryURL = URL(fileURLWithPath: arguments[2], isDirectory: true)
187-
188-
// If the download directory does not exist, create it.
189-
if !FileManager.default.fileExists(atPath: downloadDirectoryURL.path) {
190-
do {
191-
try FileManager.default.createDirectory(at: downloadDirectoryURL, withIntermediateDirectories: false)
192-
} catch {
193-
print("error: Error creating download directory: \(error.localizedDescription).")
194-
exit(1)
176+
/// Error thrown by the script.
177+
enum ScriptError: Error, LocalizedError {
178+
case invalidArguments
179+
case cannotCreateDirectory(String)
180+
case cannotParseDependencies(String)
181+
case downloadFailed
182+
183+
var errorDescription: String? {
184+
switch self {
185+
case .invalidArguments:
186+
"Invalid number of arguments."
187+
case .cannotCreateDirectory(let path):
188+
"Cannot create directory: \(path)"
189+
case .cannotParseDependencies(let reason):
190+
"Cannot parse dependencies: \(reason)"
191+
case .downloadFailed:
192+
"Failed to download all items."
193+
}
195194
}
196195
}
197196

198-
/// Portal Items created from iterating through all metadata's "offline\_data".
199-
let portalItems: Set<PortalItem> = {
197+
/// Parses all sample dependencies in the given samples directory.
198+
/// - Parameter samplesDirectoryURL: The URL to the samples directory.
199+
/// - Throws: Exceptions when unable to read or decode JSON files.
200+
/// - Returns: The portal items that represent the offline data.
201+
func parseSampleDependencies(at samplesDirectoryURL: URL) throws -> [PortalItem] {
200202
do {
201203
// Finds all subdirectories under the root Samples directory.
202204
let sampleSubDirectories = try FileManager.default
203-
.contentsOfDirectory(at: samplesDirectoryURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
205+
.contentsOfDirectory(
206+
at: samplesDirectoryURL,
207+
includingPropertiesForKeys: nil,
208+
options: [.skipsHiddenFiles]
209+
)
204210
.filter(\.hasDirectoryPath)
205211
let sampleJSONs = sampleSubDirectories
206212
.map { $0.appendingPathComponent("README.metadata.json", isDirectory: false) }
207-
// Omit the decoding errors from samples that don't have dependencies.
213+
// Omits the decoding errors from samples that don't have dependencies.
208214
let sampleDependencies = sampleJSONs
209215
.compactMap { try? parseJSON(at: $0) }
210-
return Set(sampleDependencies.lazy.flatMap(\.offlineData))
216+
// Some items are used by multiple samples and their IDs appear more
217+
// than once.
218+
// Removes duplicates by converting to a Set, then back to an Array.
219+
return Array(Set(sampleDependencies.lazy.flatMap(\.offlineData)))
211220
} catch {
212-
print("error: Error decoding Samples dependencies: \(error.localizedDescription)")
213-
exit(1)
221+
throw ScriptError.cannotParseDependencies(error.localizedDescription)
214222
}
215-
}()
216-
217-
typealias Identifier = String
218-
typealias Filename = String
219-
typealias DownloadedItems = [Identifier: Filename]
223+
}
220224

221-
/// The URL to a property list that maintains records of downloaded resources.
222-
let downloadedItemsURL = downloadDirectoryURL.appendingPathComponent(".downloaded_items.plist", isDirectory: false)
223-
let previousDownloadedItems: DownloadedItems = {
224-
do {
225-
let data = try Data(contentsOf: downloadedItemsURL)
226-
return try PropertyListDecoder().decode(DownloadedItems.self, from: data)
227-
} catch {
228-
return [:]
225+
/// The main script function.
226+
func run() async throws { // swiftlint:disable:this function_body_length cyclomatic_complexity
227+
let arguments = CommandLine.arguments
228+
guard arguments.count == 3 else {
229+
throw ScriptError.invalidArguments
229230
}
230-
}()
231-
var downloadedItems = previousDownloadedItems
232-
233-
await withTaskGroup(of: Void.self) { group in
234-
for portalItem in portalItems {
231+
232+
/// The samples directory, i.e., $SRCROOT/Shared/Samples.
233+
let samplesDirectoryURL = URL(fileURLWithPath: arguments[1], isDirectory: true)
234+
/// The download directory, i.e., $SRCROOT/Portal Data.
235+
let downloadDirectoryURL = URL(fileURLWithPath: arguments[2], isDirectory: true)
236+
237+
// If the download directory does not exist, create it.
238+
if !FileManager.default.fileExists(atPath: downloadDirectoryURL.path) {
239+
do {
240+
try FileManager.default.createDirectory(at: downloadDirectoryURL, withIntermediateDirectories: false)
241+
} catch {
242+
throw ScriptError.cannotCreateDirectory(downloadDirectoryURL.path)
243+
}
244+
}
245+
246+
/// Portal Items created from iterating through all metadata's "offline_data".
247+
let portalItems = try parseSampleDependencies(at: samplesDirectoryURL)
248+
249+
typealias Identifier = String
250+
typealias Filename = String
251+
typealias DownloadedItems = [Identifier: Filename]
252+
253+
/// The URL to a property list that maintains records of downloaded resources.
254+
let downloadedItemsURL = downloadDirectoryURL.appendingPathComponent(".downloaded_items.plist", isDirectory: false)
255+
let previousDownloadedItems: DownloadedItems = {
256+
do {
257+
let data = try Data(contentsOf: downloadedItemsURL)
258+
return try PropertyListDecoder().decode(DownloadedItems.self, from: data)
259+
} catch {
260+
return [:]
261+
}
262+
}()
263+
var downloadedItems = previousDownloadedItems
264+
265+
defer {
266+
// Updates the downloaded items property list when there are changes.
267+
// It runs at the end of the function, even if there are errors.
268+
// Because defer cannot throw, errors are caught and printed.
269+
if downloadedItems != previousDownloadedItems {
270+
do {
271+
let data = try PropertyListEncoder().encode(downloadedItems)
272+
try data.write(to: downloadedItemsURL)
273+
} catch {
274+
print("error: Error recording downloaded items: \(error.localizedDescription)")
275+
}
276+
}
277+
}
278+
279+
/// Creates a directory for the portal item in the download directory.
280+
/// - Parameter portalItem: The portal item.
281+
/// - Throws: `ScriptError.cannotCreateDirectory` when unable to create the
282+
/// directory.
283+
/// - Returns: The URL to the created directory, or `nil` if the portal item
284+
/// is already downloaded.
285+
func createDirectory(for portalItem: PortalItem) throws -> URL? {
235286
let destinationURL = downloadDirectoryURL.appendingPathComponent(
236287
portalItem.identifier,
237288
isDirectory: true
238289
)
239290

240291
// Checks to see if an item needs downloading.
292+
// Only skips if the item is in the plist and file exists at the
293+
// expected location.
241294
guard downloadedItems[portalItem.identifier] == nil ||
242295
!FileManager.default.fileExists(atPath: destinationURL.path) else {
243296
print("note: Item already downloaded: \(portalItem.identifier)")
244-
continue
297+
return nil
245298
}
246299

247300
// Deletes the directory when the item is not in the plist.
248301
try? FileManager.default.removeItem(at: destinationURL)
249302

250303
do {
251-
// Creates an enclosing directory with the portal item ID as its name.
304+
// Creates an enclosing directory with the item ID as its name.
252305
try FileManager.default.createDirectory(
253306
at: destinationURL,
254307
withIntermediateDirectories: false
255308
)
309+
return destinationURL
310+
} catch {
311+
throw ScriptError.cannotCreateDirectory(destinationURL.path)
312+
}
313+
}
314+
315+
/// Handles downloading a portal item to the given destination URL.
316+
/// - Parameters:
317+
/// - portalItem: The portal item.
318+
/// - destinationURL: The URL to the portal item download directory.
319+
/// - index: The index of the portal item in the overall list.
320+
/// - Throws: `ScriptError.downloadFailed` when unable to download the item.
321+
func handleDownload(for portalItem: PortalItem, at destinationURL: URL, index: Int) async throws {
322+
do {
323+
let downloadName = try await downloadFile(
324+
from: portalItem.dataURL,
325+
to: destinationURL
326+
)
327+
downloadedItems.updateValue(downloadName, forKey: portalItem.identifier)
328+
print("note: (\(index + 1)/\(portalItems.count)) Downloaded item: \(portalItem.identifier)")
329+
fflush(stdout)
256330
} catch {
257-
print("error: Error creating download directory: \(error.localizedDescription)")
258-
exit(1)
331+
print("error: Failed to download item \(portalItem.identifier), \(error.localizedDescription)")
332+
fflush(stdout)
333+
// Deletes the directory when the item fails to download.
334+
try? FileManager.default.removeItem(at: destinationURL)
335+
throw ScriptError.downloadFailed
336+
}
337+
}
338+
339+
/// The maximum number of concurrent download tasks, capped at 4.
340+
let maxConcurrentTasks = min(4, portalItems.count)
341+
342+
try await withThrowingTaskGroup(of: Void.self) { group in
343+
for index in 0 ..< maxConcurrentTasks {
344+
group.addTask {
345+
let portalItem = portalItems[index]
346+
guard let destinationURL = try createDirectory(for: portalItem) else { return }
347+
try await handleDownload(for: portalItem, at: destinationURL, index: index)
348+
}
259349
}
260350

261-
group.addTask {
262-
do {
263-
guard let downloadName = try await downloadFile(
264-
from: portalItem.dataURL,
265-
to: destinationURL
266-
) else { return }
267-
print("note: Downloaded item: \(portalItem.identifier)")
268-
fflush(stdout)
269-
270-
_ = await MainActor.run {
271-
downloadedItems.updateValue(downloadName, forKey: portalItem.identifier)
351+
var nextPortalItemIndex = maxConcurrentTasks
352+
// As each task completes, adds a new task until all portal items are
353+
// either downloaded, or an error occurs that causes the group to throw
354+
// and cancel all remaining tasks.
355+
while try await group.next() != nil {
356+
if nextPortalItemIndex < portalItems.count {
357+
// Captures the next index so it doesn't go out of range.
358+
group.addTask { [nextPortalItemIndex] in
359+
let portalItem = portalItems[nextPortalItemIndex]
360+
guard let destinationURL = try createDirectory(for: portalItem) else { return }
361+
try await handleDownload(for: portalItem, at: destinationURL, index: nextPortalItemIndex)
272362
}
273-
} catch {
274-
print("error: Error downloading item \(portalItem.identifier): \(error.localizedDescription)")
275-
URLSession.shared.invalidateAndCancel()
276-
exit(1)
277363
}
364+
nextPortalItemIndex += 1
278365
}
279366
}
280367
}
281368

282-
// Updates the downloaded items property list record if needed.
283-
if downloadedItems != previousDownloadedItems {
284-
do {
285-
let data = try PropertyListEncoder().encode(downloadedItems)
286-
try data.write(to: downloadedItemsURL)
287-
} catch {
288-
print("error: Error recording downloaded items: \(error.localizedDescription)")
289-
exit(1)
290-
}
369+
do {
370+
try await run()
371+
} catch {
372+
print("error: \(error.localizedDescription)")
373+
fflush(stdout)
374+
exit(1)
291375
}

Shared/Samples/Add WFS layer/AddWFSLayerView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ struct AddWFSLayerView: View {
6363
@State private var isPopulating = false
6464

6565
/// The error shown in the error alert.
66-
@State private var error: Error?
66+
@State private var error: (any Error)?
6767

6868
var body: some View {
6969
MapView(map: map)

Shared/Samples/Add WMTS layer/AddWMTSLayerView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ struct AddWMTSLayerView: View {
2323
@State private var selectedLayerSource = WMTSLayerSource.wmtsLayerInfo
2424

2525
/// The error shown in the error alert.
26-
@State private var error: Error?
26+
@State private var error: (any Error)?
2727

2828
var body: some View {
2929
MapView(map: model.map)

Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.Vessel.swift

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,13 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import ArcGIS
16+
1517
extension AddCustomDynamicEntityDataSourceView {
1618
/// A marine vessel that can be decoded from the vessel JSON.
1719
struct Vessel {
18-
/// A geometry that gives the location of the vessel.
19-
struct Geometry: Decodable { // swiftlint:disable:this nesting
20-
/// The x coordinate of the geometry.
21-
let x: Double
22-
/// The y coordinate of the geometry.
23-
let y: Double
24-
}
25-
2620
/// The location of the vessel.
27-
let geometry: Geometry
21+
let geometry: Point
2822
/// The attributes of the vessel.
2923
let attributes: [String: any Sendable]
3024
}
@@ -56,7 +50,7 @@ extension AddCustomDynamicEntityDataSourceView.Vessel: Decodable {
5650

5751
let container = try decoder.container(keyedBy: CodingKeys.self)
5852

59-
let geometry = try container.decode(Geometry.self, forKey: .geometry)
53+
let geometry = try container.decode(Point.self, forKey: .geometry)
6054
let attributes: [String: any Sendable] = try {
6155
let attributes = try container.decode(Attributes.self, forKey: .attributes)
6256
return [

Shared/Samples/Add custom dynamic entity data source/AddCustomDynamicEntityDataSourceView.swift

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,10 @@ private struct VesselFeed: CustomDynamicEntityFeed {
127127
from: line.data(using: .utf8)!
128128
)
129129

130-
// The location of the vessel that was decoded from the JSON.
131-
let location = vessel.geometry
132-
133130
// We successfully decoded the vessel JSON so we should
134131
// add that vessel as a new observation.
135132
return CustomDynamicEntityFeedEvent.newObservation(
136-
geometry: Point(x: location.x, y: location.y, spatialReference: .wgs84),
133+
geometry: vessel.geometry,
137134
attributes: vessel.attributes
138135
)
139136
}

Shared/Samples/Add feature collection layer from portal item/AddFeatureCollectionLayerFromPortalItemView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ struct AddFeatureCollectionLayerFromPortalItemView: View {
2020
@State private var map = Map(basemapStyle: .arcGISOceans)
2121

2222
/// The error shown in the error alert.
23-
@State private var error: Error?
23+
@State private var error: (any Error)?
2424

2525
var body: some View {
2626
MapView(map: map)

Shared/Samples/Add feature collection layer from query/AddFeatureCollectionLayerFromQueryView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ struct AddFeatureCollectionLayerFromQueryView: View {
2020
@State private var map = Map(basemapStyle: .arcGISOceans)
2121

2222
/// The error shown in the error alert.
23-
@State private var error: Error?
23+
@State private var error: (any Error)?
2424

2525
var body: some View {
2626
MapView(map: map)

Shared/Samples/Add feature collection layer from table/AddFeatureCollectionLayerFromTableView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ struct AddFeatureCollectionLayerFromTableView: View {
2828
}()
2929

3030
/// The error shown in the error alert.
31-
@State private var error: Error?
31+
@State private var error: (any Error)?
3232

3333
var body: some View {
3434
MapView(map: map)

0 commit comments

Comments
 (0)