2025-07-21 21:42:36 +00:00
import Foundation
import MapKit
import OSLog
2025-07-22 04:13:54 +00:00
import Combine
2025-07-21 21:42:36 +00:00
// / M a n a g e r f o r h a n d l i n g u s e r - u p l o a d e d m a p d a t a f i l e s
2025-07-22 04:13:54 +00:00
class MapDataManager : ObservableObject {
2025-07-23 20:58:46 +00:00
static let shared = MapDataManager ( )
private init ( ) { }
// MARK: - C o n s t a n t s
private let maxFileSize : Int64 = 10 * 1024 * 1024 // 1 0 M B
private let mapDataDirectory = " MapData "
private let userUploadedDirectory = " user_uploaded "
private let metadataFileName = " upload_history.json "
// MARK: - P r o p e r t i e s
@ Published private var uploadedFiles : [ MapDataMetadata ] = [ ]
private var activeFeatureCollection : GeoJSONFeatureCollection ?
// MARK: - F i l e M a n a g e m e n t
// / G e t t h e b a s e U R L f o r m a p d a t a s t o r a g e
private func getMapDataDirectory ( ) -> URL ? {
guard let documentsURL = FileManager . default . urls ( for : . documentDirectory , in : . userDomainMask ) . first else {
Logger . services . error ( " 🗂️ Could not access documents directory " )
return nil
}
return documentsURL . appendingPathComponent ( mapDataDirectory )
}
// / G e t t h e U R L f o r u s e r u p l o a d e d f i l e s
private func getUserUploadedDirectory ( ) -> URL ? {
guard let baseURL = getMapDataDirectory ( ) else { return nil }
return baseURL . appendingPathComponent ( userUploadedDirectory )
}
// / G e t t h e U R L f o r m e t a d a t a f i l e
private func getMetadataFileURL ( ) -> URL ? {
guard let baseURL = getMapDataDirectory ( ) else { return nil }
return baseURL . appendingPathComponent ( metadataFileName )
}
// / C r e a t e n e c e s s a r y d i r e c t o r i e s
private func createDirectoriesIfNeeded ( ) -> Bool {
guard let userDir = getUserUploadedDirectory ( ) else { return false }
do {
try FileManager . default . createDirectory ( at : userDir , withIntermediateDirectories : true )
return true
} catch {
Logger . services . error ( " 🗂️ Failed to create directories: \( error . localizedDescription , privacy : . public ) " )
return false
}
}
// MARK: - F i l e U p l o a d & P r o c e s s i n g
// / P r o c e s s a n d s t o r e a n u p l o a d e d f i l e
func processUploadedFile ( from sourceURL : URL ) async throws -> MapDataMetadata {
// 1 . S t a r t a c c e s s i n g s e c u r i t y - s c o p e d r e s o u r c e
let isAccessing = sourceURL . startAccessingSecurityScopedResource ( )
defer {
if isAccessing {
sourceURL . stopAccessingSecurityScopedResource ( )
}
}
// 2 . V a l i d a t e f i l e
try validateFile ( at : sourceURL )
// 2 . C r e a t e d i r e c t o r i e s i f n e e d e d
guard createDirectoriesIfNeeded ( ) else {
throw MapDataError . directoryCreationFailed
}
// 3 . G e n e r a t e d e s t i n a t i o n f i l e n a m e
let timestamp = Date ( ) . timeIntervalSince1970
let originalName = sourceURL . deletingPathExtension ( ) . lastPathComponent
let fileExtension = sourceURL . pathExtension
let newFilename = " \( originalName ) _ \( Int ( timestamp ) ) . \( fileExtension ) "
guard let destURL = getUserUploadedDirectory ( ) ? . appendingPathComponent ( newFilename ) else {
throw MapDataError . invalidDestination
}
// 4 . C o p y f i l e t o a p p s t o r a g e
try FileManager . default . copyItem ( at : sourceURL , to : destURL )
// 5 . P r o c e s s a n d v a l i d a t e c o n t e n t
let metadata = try await processFileContent ( at : destURL , originalName : originalName )
// 6 . S a v e m e t a d a t a a n d u p d a t e U I o n m a i n t h r e a d
await MainActor . run {
uploadedFiles . append ( metadata )
// C l e a r c a c h e d c o n f i g u r a t i o n t o f o r c e r e l o a d
activeFeatureCollection = nil
}
try saveMetadata ( )
return metadata
}
// / V a l i d a t e u p l o a d e d f i l e
private func validateFile ( at url : URL ) throws {
let fileAttributes = try url . resourceValues ( forKeys : [ . fileSizeKey , . isRegularFileKey ] )
// C h e c k f i l e s i z e
guard let fileSize = fileAttributes . fileSize , fileSize <= maxFileSize else {
throw MapDataError . fileTooLarge
}
// C h e c k i f i t ' s a r e g u l a r f i l e
guard fileAttributes . isRegularFile = = true else {
throw MapDataError . invalidFileType
}
// C h e c k f i l e e x t e n s i o n
let allowedExtensions = [ " json " , " geojson " ]
let fileExtension = url . pathExtension . lowercased ( )
guard allowedExtensions . contains ( fileExtension ) else {
throw MapDataError . unsupportedFormat
}
}
// / P r o c e s s f i l e c o n t e n t a n d e x t r a c t m e t a d a t a
private func processFileContent ( at url : URL , originalName : String ) async throws -> MapDataMetadata {
let fileAttributes = try url . resourceValues ( forKeys : [ . fileSizeKey , . creationDateKey ] )
let fileSize = fileAttributes . fileSize ? ? 0
let uploadDate = fileAttributes . creationDate ? ? Date ( )
// R e a d a n d p r o c e s s f i l e c o n t e n t o n b a c k g r o u n d q u e u e
let ( _ , overlayCount ) = try await withCheckedThrowingContinuation { continuation in
Task . detached {
do {
let data = try Data ( contentsOf : url )
let overlayCount = try self . getOverlayCount ( from : data )
continuation . resume ( returning : ( data , overlayCount ) )
} catch {
continuation . resume ( throwing : error )
}
}
}
2025-07-28 11:29:12 -07:00
// V a l i d a t e G e o J S O N s c h e m a
let jsonObject = try JSONSerialization . jsonObject ( with : data , options : [ ] )
guard let geoJSON = jsonObject as ? [ String : Any ] else {
throw NSError ( domain : " MapDataManager " , code : 1 , userInfo : [ NSLocalizedDescriptionKey : " Invalid GeoJSON format " ] )
}
// C h e c k r e q u i r e d p r o p e r t i e s
guard let type = geoJSON [ " type " ] as ? String , type = = " FeatureCollection " ,
let features = geoJSON [ " features " ] as ? [ [ String : Any ] ] else {
throw NSError ( domain : " MapDataManager " , code : 2 , userInfo : [ NSLocalizedDescriptionKey : " GeoJSON must be a FeatureCollection with features " ] )
}
// V a l i d a t e e a c h f e a t u r e
for feature in features {
guard let geometry = feature [ " geometry " ] as ? [ String : Any ] ,
let coordinates = geometry [ " coordinates " ] as ? [ Any ] ,
let geometryType = geometry [ " type " ] as ? String else {
throw NSError ( domain : " MapDataManager " , code : 3 , userInfo : [ NSLocalizedDescriptionKey : " Invalid feature structure in GeoJSON " ] )
}
// V a l i d a t e c o o r d i n a t e s b a s e d o n g e o m e t r y t y p e
try validateCoordinates ( coordinates , for : geometryType )
}
2025-07-23 20:58:46 +00:00
// I f t h i s i s t h e f i r s t f i l e u p l o a d e d , m a k e i t a c t i v e b y d e f a u l t
let isFirstFile = uploadedFiles . isEmpty
return MapDataMetadata (
filename : url . lastPathComponent ,
originalName : originalName ,
uploadDate : uploadDate ,
fileSize : Int64 ( fileSize ) ,
format : url . pathExtension . lowercased ( ) ,
license : nil , // W i l l b e e x t r a c t e d f r o m c o n t e n t i f a v a i l a b l e
attribution : nil , // W i l l b e e x t r a c t e d f r o m c o n t e n t i f a v a i l a b l e
overlayCount : overlayCount ,
isActive : isFirstFile
)
}
// / G e t o v e r l a y c o u n t f r o m r a w G e o J S O N d a t a
private func getOverlayCount ( from data : Data ) throws -> Int {
// P a r s e a s r a w G e o J S O N F e a t u r e C o l l e c t i o n
if let json = try JSONSerialization . jsonObject ( with : data ) as ? [ String : Any ] ,
let features = json [ " features " ] as ? [ [ String : Any ] ] {
return features . count
}
throw MapDataError . invalidContent
}
// / L o a d f e a t u r e c o l l e c t i o n f r o m a s i n g l e f i l e
private func loadFeatureCollectionFromFile ( _ file : MapDataMetadata ) throws -> GeoJSONFeatureCollection ? {
guard let fileURL = getUserUploadedDirectory ( ) ? . appendingPathComponent ( file . filename ) else {
throw MapDataError . fileNotFound
}
let data = try Data ( contentsOf : fileURL )
return try JSONDecoder ( ) . decode ( GeoJSONFeatureCollection . self , from : data )
}
// MARK: - C o n f i g u r a t i o n L o a d i n g
// / L o a d c o m b i n e d f e a t u r e c o l l e c t i o n f r o m s p e c i f i c f i l e s
func loadFeatureCollectionForFiles ( _ files : [ MapDataMetadata ] ) -> GeoJSONFeatureCollection ? {
guard ! files . isEmpty else {
return nil
}
var allFeatures : [ GeoJSONFeature ] = [ ]
for file in files {
do {
if let featureCollection = try loadFeatureCollectionFromFile ( file ) {
allFeatures . append ( contentsOf : featureCollection . features )
}
} catch {
Logger . services . error ( " 📁 MapDataManager: Failed to load feature collection from \( file . filename , privacy : . public ) : \( error . localizedDescription , privacy : . public ) " )
continue
}
}
guard ! allFeatures . isEmpty else {
return nil
}
return GeoJSONFeatureCollection ( type : " FeatureCollection " , features : allFeatures )
}
// / L o a d a n d c o m b i n e r a w G e o J S O N f e a t u r e c o l l e c t i o n s f r o m a l l a c t i v e f i l e s
func loadFeatureCollection ( ) -> GeoJSONFeatureCollection ? {
if let cached = activeFeatureCollection {
return cached
}
// F i n d a c t i v e u s e r f i l e s
let activeFiles = uploadedFiles . filter { $0 . isActive }
guard ! activeFiles . isEmpty else {
return nil
}
var allFeatures : [ GeoJSONFeature ] = [ ]
// L o a d f e a t u r e s f r o m a l l a c t i v e f i l e s
for activeFile in activeFiles {
guard let fileURL = getUserUploadedDirectory ( ) ? . appendingPathComponent ( activeFile . filename ) else {
Logger . services . error ( " 📁 MapDataManager: Could not construct file URL for: \( activeFile . filename , privacy : . public ) " )
continue
}
// C h e c k i f f i l e e x i s t s b e f o r e t r y i n g t o l o a d i t
if ! FileManager . default . fileExists ( atPath : fileURL . path ) {
Logger . services . error ( " 📁 MapDataManager: Active file does not exist at path: \( fileURL . path , privacy : . public ) " )
// R e m o v e t h e m i s s i n g f i l e f r o m o u r m e t a d a t a
if let index = uploadedFiles . firstIndex ( where : { $0 . filename = = activeFile . filename } ) {
uploadedFiles . remove ( at : index )
do {
try saveMetadata ( )
} catch {
Logger . services . error ( " 📁 MapDataManager: Failed to save cleaned metadata: \( error . localizedDescription , privacy : . public ) " )
}
}
continue
}
do {
let data = try Data ( contentsOf : fileURL )
let featureCollection = try JSONDecoder ( ) . decode ( GeoJSONFeatureCollection . self , from : data )
allFeatures . append ( contentsOf : featureCollection . features )
} catch {
Logger . services . error ( " 📁 MapDataManager: Failed to load feature collection from \( activeFile . filename , privacy : . public ) : \( error . localizedDescription , privacy : . public ) " )
}
}
// C r e a t e c o m b i n e d f e a t u r e c o l l e c t i o n
let combinedCollection = GeoJSONFeatureCollection (
type : " FeatureCollection " ,
features : allFeatures
)
activeFeatureCollection = combinedCollection
return combinedCollection
}
// MARK: - F i l e M a n a g e m e n t
// / G e t a l l u p l o a d e d f i l e s
func getUploadedFiles ( ) -> [ MapDataMetadata ] {
return uploadedFiles
}
// / T o g g l e t h e a c t i v e s t a t e o f a n u p l o a d e d f i l e
func toggleFileActive ( _ fileId : UUID ) {
if let index = uploadedFiles . firstIndex ( where : { $0 . id = = fileId } ) {
uploadedFiles [ index ] . isActive . toggle ( )
// S a v e m e t a d a t a c h a n g e s
do {
try saveMetadata ( )
// C l e a r c a c h e d d a t a t o f o r c e r e l o a d
activeFeatureCollection = nil
} catch {
Logger . services . error ( " 🚨 MapDataManager: FAILED to save metadata after toggling file: \( error . localizedDescription ) " )
}
}
}
// / D e l e t e u p l o a d e d f i l e
func deleteFile ( _ metadata : MapDataMetadata ) async throws {
guard let fileURL = getUserUploadedDirectory ( ) ? . appendingPathComponent ( metadata . filename ) else {
Logger . services . error ( " 🗑️ MapDataManager: Could not construct file URL for: \( metadata . filename , privacy : . public ) " )
throw MapDataError . fileNotFound
}
// C h e c k i f f i l e e x i s t s b e f o r e t r y i n g t o d e l e t e
if ! FileManager . default . fileExists ( atPath : fileURL . path ) {
Logger . services . warning ( " 🗑️ MapDataManager: File does not exist at path: \( fileURL . path , privacy : . public ) " )
}
do {
try FileManager . default . removeItem ( at : fileURL )
} catch {
Logger . services . error ( " 🗑️ MapDataManager: Failed to remove file: \( error . localizedDescription , privacy : . public ) " )
throw error
}
// U p d a t e U I - r e l a t e d p r o p e r t i e s o n m a i n t h r e a d
await MainActor . run {
if let index = uploadedFiles . firstIndex ( where : { $0 . filename = = metadata . filename } ) {
uploadedFiles . remove ( at : index )
} else {
Logger . services . warning ( " 🗑️ MapDataManager: File not found in uploadedFiles array " )
}
}
do {
try saveMetadata ( )
} catch {
Logger . services . error ( " 🗑️ MapDataManager: Failed to save metadata: \( error . localizedDescription , privacy : . public ) " )
throw error
}
// C l e a r c a c h e i f t h i s w a s t h e a c t i v e f i l e
await MainActor . run {
if activeFeatureCollection != nil {
activeFeatureCollection = nil
}
}
// C l e a r G e o J S O N o v e r l a y m a n a g e r c a c h e
GeoJSONOverlayManager . shared . clearCache ( )
// N o t i f y U I c o m p o n e n t s t h a t a f i l e w a s d e l e t e d
await MainActor . run {
NotificationCenter . default . post ( name : Foundation . Notification . Name . mapDataFileDeleted , object : metadata . id )
}
}
// MARK: - M e t a d a t a P e r s i s t e n c e
// / L o a d m e t a d a t a f r o m d i s k
func loadMetadata ( ) {
guard let metadataURL = getMetadataFileURL ( ) ,
let data = try ? Data ( contentsOf : metadataURL ) ,
let files = try ? JSONDecoder ( ) . decode ( [ MapDataMetadata ] . self , from : data ) else {
uploadedFiles = [ ]
return
}
uploadedFiles = files
}
// / S a v e m e t a d a t a t o d i s k
private func saveMetadata ( ) throws {
guard let metadataURL = getMetadataFileURL ( ) else {
throw MapDataError . invalidDestination
}
let data = try JSONEncoder ( ) . encode ( uploadedFiles )
try data . write ( to : metadataURL )
}
// MARK: - I n i t i a l i z a t i o n
// / I n i t i a l i z e t h e m a n a g e r
func initialize ( ) {
loadMetadata ( )
}
2025-07-21 21:42:36 +00:00
}
// MARK: - S u p p o r t i n g T y p e s
// / M e t a d a t a f o r u p l o a d e d m a p d a t a f i l e s
struct MapDataMetadata : Codable , Identifiable {
2025-07-23 20:58:46 +00:00
let id : UUID
let filename : String
let originalName : String
let uploadDate : Date
let fileSize : Int64
let format : String
let license : String ?
let attribution : String ?
let overlayCount : Int
var isActive : Bool
init ( filename : String , originalName : String , uploadDate : Date , fileSize : Int64 , format : String , license : String ? , attribution : String ? , overlayCount : Int , isActive : Bool ) {
self . id = UUID ( )
self . filename = filename
self . originalName = originalName
self . uploadDate = uploadDate
self . fileSize = fileSize
self . format = format
self . license = license
self . attribution = attribution
self . overlayCount = overlayCount
self . isActive = isActive
}
var fileSizeString : String {
let formatter = ByteCountFormatter ( )
formatter . allowedUnits = [ . useKB , . useMB ]
formatter . countStyle = . file
return formatter . string ( fromByteCount : fileSize )
}
2025-07-21 21:42:36 +00:00
}
// / E r r o r s t h a t c a n o c c u r d u r i n g m a p d a t a o p e r a t i o n s
enum MapDataError : Error , LocalizedError {
2025-07-23 20:58:46 +00:00
case fileTooLarge
case invalidFileType
case unsupportedFormat
case invalidContent
case directoryCreationFailed
case invalidDestination
case fileNotFound
case saveFailed
var errorDescription : String ? {
switch self {
case . fileTooLarge :
return " File is too large. Maximum size is 10MB. "
case . invalidFileType :
return " Invalid file type. Please select a regular file. "
case . unsupportedFormat :
return " Unsupported file format. Supported formats: JSON, GeoJSON, KML, KMZ, GZ, ZLIB. "
case . invalidContent :
return " Invalid file content. Please check the file format. "
case . directoryCreationFailed :
return " Failed to create storage directory. "
case . invalidDestination :
return " Invalid destination path. "
case . fileNotFound :
return " File not found. "
case . saveFailed :
return " Failed to save file. "
}
}
2025-07-22 04:13:54 +00:00
}
// MARK: - N o t i f i c a t i o n N a m e s
extension Foundation . Notification . Name {
2025-07-23 20:58:46 +00:00
static let mapDataFileDeleted = Foundation . Notification . Name ( " mapDataFileDeleted " )
2025-07-23 20:26:50 +00:00
}