2025-07-21 21:42:36 +00:00
import Foundation
import MapKit
import OSLog
// / 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
class MapDataManager {
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
private var uploadedFiles : [ MapDataMetadata ] = [ ]
2025-07-22 00:48:50 +00:00
private var activeFeatureCollection : GeoJSONFeatureCollection ?
2025-07-21 21:42:36 +00:00
// 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
uploadedFiles . append ( metadata )
try saveMetadata ( )
// 7 . 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
2025-07-22 00:48:50 +00:00
activeFeatureCollection = nil
2025-07-21 21:42:36 +00:00
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
2025-07-22 03:23:43 +00:00
let allowedExtensions = [ " json " , " geojson " ]
2025-07-21 21:42:36 +00:00
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
2025-07-22 00:48:50 +00:00
let ( _ , overlayCount ) = try await withCheckedThrowingContinuation { continuation in
2025-07-21 21:42:36 +00:00
Task . detached {
do {
let data = try Data ( contentsOf : url )
2025-07-22 03:23:43 +00:00
let overlayCount = try self . getOverlayCount ( from : data )
continuation . resume ( returning : ( data , overlayCount ) )
2025-07-21 21:42:36 +00:00
} catch {
continuation . resume ( throwing : error )
}
}
}
2025-07-22 03:23:43 +00:00
// TODO: A d d p r o p e r G e o J S O N s c h e m a v a l i d a t i o n h e r e
// - V a l i d a t e r e q u i r e d p r o p e r t i e s ( t y p e , f e a t u r e s )
// - V a l i d a t e g e o m e t r y t y p e s a n d c o o r d i n a t e s
// - V a l i d a t e f e a t u r e s t r u c t u r e
// - C o n s i d e r u s i n g J S O N S c h e m a v a l i d a t i o n
// - E n s u r e c o o r d i n a t e s a r e w i t h i n v a l i d r a n g e s ( l a t : - 9 0 t o 9 0 , l o n : - 1 8 0 t o 1 8 0 )
// - V a l i d a t e t h a t f e a t u r e p r o p e r t i e s f o l l o w e x p e c t e d p a t t e r n s
2025-07-21 21:42:36 +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
)
}
2025-07-22 00:48:50 +00:00
// / 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
2025-07-21 21:42:36 +00:00
private func getOverlayCount ( from data : Data ) throws -> Int {
2025-07-22 00:48:50 +00:00
// 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
2025-07-21 21:42:36 +00:00
}
2025-07-22 00:48:50 +00:00
throw MapDataError . invalidContent
2025-07-21 21:42:36 +00:00
}
2025-07-22 03:23:43 +00:00
// / 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 )
}
2025-07-21 21:42:36 +00:00
// MARK: - C o n f i g u r a t i o n L o a d i n g
2025-07-22 02:03:36 +00:00
// / 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 )
}
2025-07-22 00:48:50 +00:00
// / 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 {
2025-07-21 21:42:36 +00:00
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 }
2025-07-22 00:48:50 +00:00
guard ! activeFiles . isEmpty else {
2025-07-21 21:42:36 +00:00
return nil
}
2025-07-22 00:48:50 +00:00
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 {
2025-07-21 21:42:36 +00:00
2025-07-22 00:48:50 +00:00
guard let fileURL = getUserUploadedDirectory ( ) ? . appendingPathComponent ( activeFile . filename ) else {
Logger . services . error ( " 📁 MapDataManager: Could not construct file URL for: \( activeFile . filename , privacy : . public ) " )
continue
}
2025-07-21 21:42:36 +00:00
2025-07-22 00:48:50 +00:00
// 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 )
2025-07-22 03:23:43 +00:00
let featureCollection = try JSONDecoder ( ) . decode ( GeoJSONFeatureCollection . self , from : data )
2025-07-22 00:48:50 +00:00
allFeatures . append ( contentsOf : featureCollection . features )
} catch {
Logger . services . error ( " 📁 MapDataManager: Failed to load feature collection from \( activeFile . filename , privacy : . public ) : \( error . localizedDescription , privacy : . public ) " )
}
2025-07-21 21:42:36 +00:00
}
2025-07-22 00:48:50 +00:00
// 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
2025-07-21 21:42:36 +00:00
}
// 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
}
2025-07-22 00:48:50 +00:00
// / 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 {
2025-07-22 02:03:36 +00:00
Logger . services . error ( " 🚨 MapDataManager: FAILED to save metadata after toggling file: \( error . localizedDescription ) " )
2025-07-22 00:48:50 +00:00
}
}
}
2025-07-21 21:42:36 +00:00
// / D e l e t e u p l o a d e d f i l e
2025-07-22 03:23:43 +00:00
func deleteFile ( _ metadata : MapDataMetadata ) async throws {
2025-07-22 00:48:50 +00:00
2025-07-21 21:42:36 +00:00
guard let fileURL = getUserUploadedDirectory ( ) ? . appendingPathComponent ( metadata . filename ) else {
2025-07-22 00:48:50 +00:00
Logger . services . error ( " 🗑️ MapDataManager: Could not construct file URL for: \( metadata . filename , privacy : . public ) " )
2025-07-21 21:42:36 +00:00
throw MapDataError . fileNotFound
}
2025-07-22 00:48:50 +00:00
// 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
}
2025-07-21 21:42:36 +00:00
if let index = uploadedFiles . firstIndex ( where : { $0 . filename = = metadata . filename } ) {
uploadedFiles . remove ( at : index )
2025-07-22 00:48:50 +00:00
} else {
Logger . services . warning ( " 🗑️ MapDataManager: File not found in uploadedFiles array " )
2025-07-21 21:42:36 +00:00
}
2025-07-22 00:48:50 +00:00
do {
try saveMetadata ( )
} catch {
Logger . services . error ( " 🗑️ MapDataManager: Failed to save metadata: \( error . localizedDescription , privacy : . public ) " )
throw error
}
2025-07-21 21:42:36 +00:00
// 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
2025-07-22 00:48:50 +00:00
if activeFeatureCollection != nil {
activeFeatureCollection = nil
2025-07-21 21:42:36 +00:00
}
2025-07-22 00:48:50 +00:00
// 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 ( )
2025-07-21 21:42:36 +00:00
}
// 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 ( )
}
}
// 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 {
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 )
}
var uploadDateString : String {
let formatter = DateFormatter ( )
formatter . dateStyle = . medium
formatter . timeStyle = . short
return formatter . string ( from : uploadDate )
}
}
// / 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 {
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. "
}
}
}