├── LICENSE ├── README.md ├── fixgps.swift └── fixtimezone.swift /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lakr Aream 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CameraTools 2 | 3 | This repository contains useful command line tools written in Swift providing access to EXIF metadata and fix problems with those pictures you take. 4 | 5 | - insert location data from GPS-recording app based on timestamp 6 | - fix the time zone 7 | 8 | ## License 9 | 10 | [MIT License](./LICENSE) 11 | 12 | --- 13 | 14 | Copyright © 2023 Lakr Aream. All Rights Reserved. -------------------------------------------------------------------------------- /fixgps.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env xcrun swift 2 | 3 | // 4 | // fixgps.swift 5 | // GPSFix 6 | // 7 | // Created by Lakr Aream on 2022/8/25. 8 | // 9 | 10 | import Cocoa 11 | import CoreLocation 12 | 13 | guard CommandLine.arguments.count == 3 || CommandLine.arguments.count == 4 else { 14 | print("[!] unrecognized command line argument format") 15 | print("[i] \(CommandLine.arguments[0]) /path/to/location.csv /path/to/photo/dir any(for overwrite)") 16 | print("eg. \(CommandLine.arguments[0]) /path/to/location.csv /path/to/photo/dir") 17 | print("eg. \(CommandLine.arguments[0]) /path/to/location.csv /path/to/photo/dir 1") 18 | exit(1) 19 | } 20 | 21 | let gpsFile = URL(fileURLWithPath: CommandLine.arguments[1]) 22 | let searchDir = URL(fileURLWithPath: CommandLine.arguments[2]) 23 | let overwrite = CommandLine.arguments.count == 4 24 | 25 | struct LocationRecord: Codable { 26 | let timestamp: Double 27 | let longitude: Double 28 | let latitude: Double 29 | let altitude: Double 30 | let heading: Double 31 | let speed: Double 32 | } 33 | 34 | var locationList: [LocationRecord] = [] 35 | 36 | print("[i] reading from \(gpsFile.path)") 37 | do { 38 | let csv = try CSV(url: gpsFile) 39 | for row in csv.rows { 40 | // read LocationRecord from row as? [String : String] and convert to double all keys 41 | guard let strTimestamp = row["dataTime"], 42 | let strLongitude = row["longitude"], 43 | let strLatitude = row["latitude"], 44 | let strAltitude = row["altitude"], 45 | let strHeading = row["heading"], 46 | let strSpeed = row["speed"] 47 | else { 48 | continue 49 | } 50 | guard let timestamp = Double(strTimestamp), 51 | let longitude = Double(strLongitude), 52 | let latitude = Double(strLatitude), 53 | let altitude = Double(strAltitude), 54 | let heading = Double(strHeading), 55 | let speed = Double(strSpeed) 56 | else { 57 | continue 58 | } 59 | let record = LocationRecord( 60 | timestamp: timestamp, 61 | longitude: longitude, 62 | latitude: latitude, 63 | altitude: altitude, 64 | heading: heading, 65 | speed: speed 66 | ) 67 | locationList.append(record) 68 | } 69 | } catch { 70 | print("[E] unable to read from csv \(error.localizedDescription)") 71 | exit(-1) 72 | } 73 | 74 | print("[*] preparing \(locationList.count) gps record") 75 | locationList.sort { $0.timestamp < $1.timestamp } 76 | 77 | print("[*] loaded \(locationList.count) locations") 78 | 79 | func obtainNearestLocation(forTimestamp: Double) -> LocationRecord? { 80 | var left = 0 81 | var right = locationList.count - 1 82 | while left < right { 83 | let mid = (left + right) / 2 84 | let loc = locationList[mid] 85 | if loc.timestamp == forTimestamp { 86 | left = mid 87 | right = mid 88 | break 89 | } else if loc.timestamp < forTimestamp { 90 | left = mid + 1 91 | } else { 92 | right = mid - 1 93 | } 94 | } 95 | let mid = (left + right) / 2 96 | var candidate: LocationRecord? 97 | var minDelta: Double? 98 | for idx in mid - 2 ... mid + 2 { 99 | if idx >= 0, idx < locationList.count { 100 | let loc = locationList[idx] 101 | let delta = abs(loc.timestamp - forTimestamp) 102 | if minDelta == nil || minDelta! > delta { 103 | minDelta = delta 104 | candidate = loc 105 | } 106 | } 107 | } 108 | return candidate 109 | } 110 | 111 | func readingTimestamp(imageFile: URL) -> Date? { 112 | guard let dataProvider = CGDataProvider(filename: imageFile.path), 113 | let data = dataProvider.data, 114 | let imageSource = CGImageSourceCreateWithData(data, nil), 115 | let imageProperties = CGImageSourceCopyMetadataAtIndex(imageSource, 0, nil) 116 | else { 117 | print("[E] unable to load image") 118 | return nil 119 | } 120 | guard let dateTag = CGImageMetadataCopyTagMatchingImageProperty( 121 | imageProperties, 122 | kCGImagePropertyExifDictionary, 123 | kCGImagePropertyExifDateTimeDigitized 124 | ), let offsetTag = CGImageMetadataCopyTagMatchingImageProperty( 125 | imageProperties, 126 | kCGImagePropertyExifDictionary, 127 | kCGImagePropertyExifOffsetTimeDigitized 128 | ) else { 129 | print("[E] unable to read image tags") 130 | return nil 131 | } 132 | let date = CGImageMetadataTagCopyValue(dateTag) as? String 133 | let offset = CGImageMetadataTagCopyValue(offsetTag) as? String 134 | guard let date, let offset else { 135 | print("[E] unable to read image date") 136 | return nil 137 | } 138 | let str = date + " " + offset 139 | let fmt = DateFormatter() 140 | fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS Z" 141 | return fmt.date(from: str) 142 | } 143 | 144 | func appendingGPSData(imageFile: URL, lat: Double, lon: Double, alt: Double, overwrite: Bool = false) { 145 | guard let dataProvider = CGDataProvider(filename: imageFile.path), 146 | let data = dataProvider.data, 147 | let cgImage = NSImage(data: data as Data)? 148 | .cgImage(forProposedRect: nil, context: nil, hints: nil) 149 | else { 150 | print("[E] unable to prepare data") 151 | return 152 | } 153 | 154 | let mutableData = NSMutableData(data: data as Data) 155 | 156 | guard let imageSource = CGImageSourceCreateWithData(data, nil), 157 | let type = CGImageSourceGetType(imageSource), 158 | let imageDestination = CGImageDestinationCreateWithData(mutableData, type, 1, nil), 159 | let imageProperties = CGImageSourceCopyMetadataAtIndex(imageSource, 0, nil), 160 | let mutableMetadata = CGImageMetadataCreateMutableCopy(imageProperties) 161 | else { 162 | print("[E] unable to load image") 163 | return 164 | } 165 | 166 | if CGImageMetadataCopyTagMatchingImageProperty( 167 | imageProperties, 168 | kCGImagePropertyGPSDictionary, 169 | kCGImagePropertyGPSLatitude 170 | ) != nil || CGImageMetadataCopyTagMatchingImageProperty( 171 | imageProperties, 172 | kCGImagePropertyGPSDictionary, 173 | kCGImagePropertyGPSLongitude 174 | ) != nil { 175 | print("[i] GPS data exists") 176 | if !overwrite { return } 177 | } 178 | 179 | let coornidate2D = CLLocationCoordinate2D(latitude: .init(lat), longitude: .init(lon)) 180 | 181 | CGImageMetadataSetValueMatchingImageProperty( 182 | mutableMetadata, 183 | kCGImagePropertyGPSDictionary, 184 | kCGImagePropertyGPSLatitudeRef, 185 | (lat < 0 ? "S" : "N") as CFTypeRef 186 | ) 187 | CGImageMetadataSetValueMatchingImageProperty( 188 | mutableMetadata, 189 | kCGImagePropertyGPSDictionary, 190 | kCGImagePropertyGPSLatitude, 191 | coornidate2D.latitude as CFTypeRef 192 | ) 193 | CGImageMetadataSetValueMatchingImageProperty( 194 | mutableMetadata, 195 | kCGImagePropertyGPSDictionary, 196 | kCGImagePropertyGPSLongitudeRef, 197 | (lon < 0 ? "W" : "E") as CFTypeRef 198 | ) 199 | CGImageMetadataSetValueMatchingImageProperty( 200 | mutableMetadata, 201 | kCGImagePropertyGPSDictionary, 202 | kCGImagePropertyGPSLongitude, 203 | coornidate2D.longitude as CFTypeRef 204 | ) 205 | CGImageMetadataSetValueMatchingImageProperty( 206 | mutableMetadata, 207 | kCGImagePropertyGPSDictionary, 208 | kCGImagePropertyGPSAltitude, 209 | alt as CFTypeRef 210 | ) 211 | 212 | let finalMetadata = mutableMetadata as CGImageMetadata 213 | CGImageDestinationAddImageAndMetadata(imageDestination, cgImage, finalMetadata, nil) 214 | guard CGImageDestinationFinalize(imageDestination) else { 215 | print("[E] failed to finalize image data") 216 | return 217 | } 218 | 219 | do { 220 | try FileManager.default.removeItem(at: imageFile) 221 | try mutableData.write(toFile: imageFile.path) 222 | } catch { 223 | print("[E] failed to write") 224 | print(error.localizedDescription) 225 | return 226 | } 227 | 228 | print("[*] image meta data updated") 229 | } 230 | 231 | print("[*] starting file walk inside \(searchDir.path)") 232 | 233 | let enumerator = FileManager.default.enumerator(atPath: searchDir.path) 234 | var candidates = [URL]() 235 | while let subPath = enumerator?.nextObject() as? String { 236 | guard subPath.lowercased().hasSuffix("jpg") || subPath.lowercased().hasSuffix("jpeg") else { continue } 237 | let file = searchDir.appendingPathComponent(subPath) 238 | candidates.append(file) 239 | } 240 | 241 | print("[*] found \(candidates.count) candidates") 242 | 243 | guard candidates.count > 0 else { 244 | print("no candidates found!") 245 | exit(1) 246 | } 247 | 248 | let paddingLength = String(candidates.count).count 249 | for (idx, url) in candidates.enumerated() { 250 | print("[*] processing \(idx.paddedString(totalLength: paddingLength))/\(candidates.count) <\(url.lastPathComponent)>: ", separator: "", terminator: "") 251 | fflush(stdout) 252 | autoreleasepool { 253 | guard let date = readingTimestamp(imageFile: url) else { 254 | return 255 | } 256 | guard let location = obtainNearestLocation(forTimestamp: date.timeIntervalSince1970) else { 257 | print("[E] unable to determine location") 258 | return 259 | } 260 | appendingGPSData( 261 | imageFile: url, 262 | lat: location.latitude, 263 | lon: location.longitude, 264 | alt: location.altitude, 265 | overwrite: overwrite 266 | ) 267 | } 268 | } 269 | 270 | print("[*] completed update") 271 | 272 | // helpers 273 | 274 | extension Int { 275 | func paddedString(totalLength: Int) -> String { 276 | var str = String(self) 277 | while str.count < totalLength { 278 | str = "0" + str 279 | } 280 | return str 281 | } 282 | } 283 | 284 | // ===================================== 285 | // SwiftCSV 286 | // ===================================== 287 | 288 | // 289 | // CSV+DelimiterGuessing.swift 290 | // SwiftCSV 291 | // 292 | // Created by Christian Tietze on 21.12.21. 293 | // Copyright © 2021 SwiftCSV. All rights reserved. 294 | // 295 | 296 | import Foundation 297 | 298 | extension CSVDelimiter { 299 | public static let recognized: [CSVDelimiter] = [.comma, .tab, .semicolon] 300 | 301 | /// - Returns: Delimiter between cells based on the first line in the CSV. Falls back to `.comma`. 302 | public static func guessed(string: String) -> CSVDelimiter { 303 | let recognizedDelimiterCharacters = CSVDelimiter.recognized.map(\.rawValue) 304 | 305 | // Trim newline and spaces, but keep tabs (as delimiters) 306 | var trimmedCharacters = CharacterSet.whitespacesAndNewlines 307 | trimmedCharacters.remove("\t") 308 | let line = string.trimmingCharacters(in: trimmedCharacters).firstLine 309 | 310 | var index = line.startIndex 311 | while index < line.endIndex { 312 | let character = line[index] 313 | switch character { 314 | case "\"": 315 | // When encountering an open quote, skip to the closing counterpart. 316 | // If none is found, skip to end of line. 317 | 318 | // 1) Advance one character to skip the quote 319 | index = line.index(after: index) 320 | 321 | // 2) Look for the closing quote and move current position after it 322 | if index < line.endIndex, 323 | let closingQuoteInddex = line[index...].firstIndex(of: character) 324 | { 325 | index = line.index(after: closingQuoteInddex) 326 | } else { 327 | index = line.endIndex 328 | } 329 | case _ where recognizedDelimiterCharacters.contains(character): 330 | return CSVDelimiter(rawValue: character) 331 | default: 332 | index = line.index(after: index) 333 | } 334 | } 335 | 336 | // Fallback value 337 | return .comma 338 | } 339 | } 340 | 341 | // 342 | // CSV.swift 343 | // SwiftCSV 344 | // 345 | // Created by Naoto Kaneko on 2/18/16. 346 | // Copyright © 2016 Naoto Kaneko. All rights reserved. 347 | // 348 | 349 | import Foundation 350 | 351 | public protocol CSVView { 352 | associatedtype Row 353 | associatedtype Columns 354 | 355 | var rows: [Row] { get } 356 | 357 | /// Is `nil` if `loadColumns` was set to `false`. 358 | var columns: Columns? { get } 359 | 360 | init(header: [String], text: String, delimiter: CSVDelimiter, loadColumns: Bool, rowLimit: Int?) throws 361 | 362 | func serialize(header: [String], delimiter: CSVDelimiter) -> String 363 | } 364 | 365 | /// CSV variant for which unique column names are assumed. 366 | /// 367 | /// Example: 368 | /// 369 | /// let csv = NamedCSV(...) 370 | /// let allIDs = csv.columns["id"] 371 | /// let firstEntry = csv.rows[0] 372 | /// let fullName = firstEntry["firstName"] + " " + firstEntry["lastName"] 373 | /// 374 | public typealias NamedCSV = CSV 375 | 376 | /// CSV variant that exposes columns and rows as arrays. 377 | /// Example: 378 | /// 379 | /// let csv = EnumeratedCSV(...) 380 | /// let allIds = csv.columns.filter { $0.header == "id" }.rows 381 | /// 382 | public typealias EnumeratedCSV = CSV 383 | 384 | /// For convenience, there's `EnumeratedCSV` to access fields in rows by their column index, 385 | /// and `NamedCSV` to access fields by their column names as defined in a header row. 386 | open class CSV { 387 | public let header: [String] 388 | 389 | /// Unparsed contents. 390 | public let text: String 391 | 392 | /// Used delimiter to parse `text` and to serialize the data again. 393 | public let delimiter: CSVDelimiter 394 | 395 | /// Underlying data representation of the CSV contents. 396 | public let content: DataView 397 | 398 | public var rows: [DataView.Row] { 399 | content.rows 400 | } 401 | 402 | /// Is `nil` if `loadColumns` was set to `false` during initialization. 403 | public var columns: DataView.Columns? { 404 | content.columns 405 | } 406 | 407 | /// Load CSV data from a string. 408 | /// 409 | /// - Parameters: 410 | /// - string: CSV contents to parse. 411 | /// - delimiter: Character used to separate cells from one another in rows. 412 | /// - loadColumns: Whether to populate the `columns` dictionary (default is `true`) 413 | /// - rowLimit: Amount of rows to parse (default is `nil`). 414 | /// - Throws: `CSVParseError` when parsing `string` fails. 415 | public init(string: String, delimiter: CSVDelimiter, loadColumns: Bool = true, rowLimit: Int? = nil) throws { 416 | text = string 417 | self.delimiter = delimiter 418 | header = try Parser.array(text: string, delimiter: delimiter, rowLimit: 1).first ?? [] 419 | content = try DataView(header: header, text: text, delimiter: delimiter, loadColumns: loadColumns, rowLimit: rowLimit) 420 | } 421 | 422 | /// Load CSV data from a string and guess its delimiter from `CSV.recognizedDelimiters`, falling back to `.comma`. 423 | /// 424 | /// - parameter string: CSV contents to parse. 425 | /// - parameter loadColumns: Whether to populate the `columns` dictionary (default is `true`) 426 | /// - throws: `CSVParseError` when parsing `string` fails. 427 | public convenience init(string: String, loadColumns: Bool = true) throws { 428 | let delimiter = CSVDelimiter.guessed(string: string) 429 | try self.init(string: string, delimiter: delimiter, loadColumns: loadColumns) 430 | } 431 | 432 | /// Turn the CSV data into NSData using a given encoding 433 | open func dataUsingEncoding(_ encoding: String.Encoding) -> Data? { 434 | serialized.data(using: encoding) 435 | } 436 | 437 | /// Serialized form of the CSV data; depending on the View used, this may 438 | /// perform additional normalizations. 439 | open var serialized: String { 440 | content.serialize(header: header, delimiter: delimiter) 441 | } 442 | } 443 | 444 | extension CSV: CustomStringConvertible { 445 | public var description: String { 446 | serialized 447 | } 448 | } 449 | 450 | func enquoteContentsIfNeeded(cell: String) -> String { 451 | // Add quotes if value contains a comma 452 | if cell.contains(",") { 453 | return "\"\(cell)\"" 454 | } 455 | return cell 456 | } 457 | 458 | extension CSV { 459 | /// Load a CSV file from `url`. 460 | /// 461 | /// - Parameters: 462 | /// - url: URL of the file (will be passed to `String(contentsOfURL:encoding:)` to load) 463 | /// - delimiter: Character used to separate separate cells from one another in rows. 464 | /// - encoding: Character encoding to read file (default is `.utf8`) 465 | /// - loadColumns: Whether to populate the columns dictionary (default is `true`) 466 | /// - Throws: `CSVParseError` when parsing the contents of `url` fails, or file loading errors. 467 | public convenience init(url: URL, delimiter: CSVDelimiter, encoding: String.Encoding = .utf8, loadColumns: Bool = true) throws { 468 | let contents = try String(contentsOf: url, encoding: encoding) 469 | 470 | try self.init(string: contents, delimiter: delimiter, loadColumns: loadColumns) 471 | } 472 | 473 | /// Load a CSV file from `url` and guess its delimiter from `CSV.recognizedDelimiters`, falling back to `.comma`. 474 | /// 475 | /// - Parameters: 476 | /// - url: URL of the file (will be passed to `String(contentsOfURL:encoding:)` to load) 477 | /// - encoding: Character encoding to read file (default is `.utf8`) 478 | /// - loadColumns: Whether to populate the columns dictionary (default is `true`) 479 | /// - Throws: `CSVParseError` when parsing the contents of `url` fails, or file loading errors. 480 | public convenience init(url: URL, encoding: String.Encoding = .utf8, loadColumns: Bool = true) throws { 481 | let contents = try String(contentsOf: url, encoding: encoding) 482 | 483 | try self.init(string: contents, loadColumns: loadColumns) 484 | } 485 | } 486 | 487 | extension CSV { 488 | /// Load a CSV file as a named resource from `bundle`. 489 | /// 490 | /// - Parameters: 491 | /// - name: Name of the file resource inside `bundle`. 492 | /// - ext: File extension of the resource; use `nil` to load the first file matching the name (default is `nil`) 493 | /// - bundle: `Bundle` to use for resource lookup (default is `.main`) 494 | /// - delimiter: Character used to separate separate cells from one another in rows. 495 | /// - encoding: encoding used to read file (default is `.utf8`) 496 | /// - loadColumns: Whether to populate the columns dictionary (default is `true`) 497 | /// - Throws: `CSVParseError` when parsing the contents of the resource fails, or file loading errors. 498 | /// - Returns: `nil` if the resource could not be found 499 | public convenience init?(name: String, extension ext: String? = nil, bundle: Bundle = .main, delimiter: CSVDelimiter, encoding: String.Encoding = .utf8, loadColumns: Bool = true) throws { 500 | guard let url = bundle.url(forResource: name, withExtension: ext) else { 501 | return nil 502 | } 503 | try self.init(url: url, delimiter: delimiter, encoding: encoding, loadColumns: loadColumns) 504 | } 505 | 506 | /// Load a CSV file as a named resource from `bundle` and guess its delimiter from `CSV.recognizedDelimiters`, falling back to `.comma`. 507 | /// 508 | /// - Parameters: 509 | /// - name: Name of the file resource inside `bundle`. 510 | /// - ext: File extension of the resource; use `nil` to load the first file matching the name (default is `nil`) 511 | /// - bundle: `Bundle` to use for resource lookup (default is `.main`) 512 | /// - encoding: encoding used to read file (default is `.utf8`) 513 | /// - loadColumns: Whether to populate the columns dictionary (default is `true`) 514 | /// - Throws: `CSVParseError` when parsing the contents of the resource fails, or file loading errors. 515 | /// - Returns: `nil` if the resource could not be found 516 | public convenience init?(name: String, extension ext: String? = nil, bundle: Bundle = .main, encoding: String.Encoding = .utf8, loadColumns: Bool = true) throws { 517 | guard let url = bundle.url(forResource: name, withExtension: ext) else { 518 | return nil 519 | } 520 | try self.init(url: url, encoding: encoding, loadColumns: loadColumns) 521 | } 522 | } 523 | 524 | // 525 | // CSVDelimiter.swift 526 | // SwiftCSV 527 | // 528 | // Created by Christian Tietze on 01.07.22. 529 | // Copyright © 2022 SwiftCSV. All rights reserved. 530 | // 531 | 532 | public enum CSVDelimiter: Equatable, ExpressibleByUnicodeScalarLiteral { 533 | public typealias UnicodeScalarLiteralType = Character 534 | 535 | case comma, semicolon, tab 536 | case character(Character) 537 | 538 | public init(unicodeScalarLiteral: Character) { 539 | self.init(rawValue: unicodeScalarLiteral) 540 | } 541 | 542 | init(rawValue: Character) { 543 | switch rawValue { 544 | case ",": self = .comma 545 | case ";": self = .semicolon 546 | case "\t": self = .tab 547 | default: self = .character(rawValue) 548 | } 549 | } 550 | 551 | public var rawValue: Character { 552 | switch self { 553 | case .comma: return "," 554 | case .semicolon: return ";" 555 | case .tab: return "\t" 556 | case let .character(character): return character 557 | } 558 | } 559 | } 560 | 561 | // 562 | // EnumeratedCSVView.swift 563 | // SwiftCSV 564 | // 565 | // Created by Christian Tietze on 25/10/16. 566 | // Copyright © 2016 Naoto Kaneko. All rights reserved. 567 | // 568 | 569 | import Foundation 570 | 571 | public struct Enumerated: CSVView { 572 | public struct Column: Equatable { 573 | public let header: String 574 | public let rows: [String] 575 | } 576 | 577 | public typealias Row = [String] 578 | public typealias Columns = [Column] 579 | 580 | public private(set) var rows: [Row] 581 | public private(set) var columns: Columns? 582 | 583 | public init(header: [String], text: String, delimiter: CSVDelimiter, loadColumns: Bool = false, rowLimit: Int? = nil) throws { 584 | rows = try { 585 | var rows: [Row] = [] 586 | try Parser.enumerateAsArray(text: text, delimiter: delimiter, startAt: 1, rowLimit: rowLimit) { fields in 587 | rows.append(fields) 588 | } 589 | 590 | // Fill in gaps at the end of rows that are too short. 591 | return makingRectangular(rows: rows) 592 | }() 593 | 594 | columns = { 595 | guard loadColumns else { return nil } 596 | return header.enumerated().map { (index: Int, header: String) -> Column in 597 | Column( 598 | header: header, 599 | rows: rows.map { $0[safe: index] ?? "" } 600 | ) 601 | } 602 | }() 603 | } 604 | 605 | public func serialize(header: [String], delimiter: CSVDelimiter) -> String { 606 | let separator = String(delimiter.rawValue) 607 | 608 | let head = header 609 | .map(enquoteContentsIfNeeded(cell:)) 610 | .joined(separator: separator) + "\n" 611 | 612 | let content = rows.map { row in 613 | row.map(enquoteContentsIfNeeded(cell:)) 614 | .joined(separator: separator) 615 | }.joined(separator: "\n") 616 | 617 | return head + content 618 | } 619 | } 620 | 621 | extension Collection { 622 | subscript(safe index: Self.Index) -> Self.Iterator.Element? { 623 | index < endIndex ? self[index] : nil 624 | } 625 | } 626 | 627 | fileprivate func makingRectangular(rows: [[String]]) -> [[String]] { 628 | let cellsPerRow = rows.map(\.count).max() ?? 0 629 | return rows.map { row -> [String] in 630 | let missingCellCount = cellsPerRow - row.count 631 | let appendix = Array(repeating: "", count: missingCellCount) 632 | return row + appendix 633 | } 634 | } 635 | 636 | // 637 | // NamedCSVView.swift 638 | // SwiftCSV 639 | // 640 | // Created by Christian Tietze on 22/10/16. 641 | // Copyright © 2016 Naoto Kaneko. All rights reserved. 642 | // 643 | 644 | public struct Named: CSVView { 645 | public typealias Row = [String: String] 646 | public typealias Columns = [String: [String]] 647 | 648 | public var rows: [Row] 649 | public var columns: Columns? 650 | 651 | public init(header: [String], text: String, delimiter: CSVDelimiter, loadColumns: Bool = false, rowLimit: Int? = nil) throws { 652 | rows = try { 653 | var rows: [Row] = [] 654 | try Parser.enumerateAsDict(header: header, content: text, delimiter: delimiter, rowLimit: rowLimit) { dict in 655 | rows.append(dict) 656 | } 657 | return rows 658 | }() 659 | 660 | columns = { 661 | guard loadColumns else { return nil } 662 | var columns: Columns = [:] 663 | for field in header { 664 | columns[field] = rows.map { $0[field] ?? "" } 665 | } 666 | return columns 667 | }() 668 | } 669 | 670 | public func serialize(header: [String], delimiter: CSVDelimiter) -> String { 671 | let separator = String(delimiter.rawValue) 672 | 673 | let head = header 674 | .map(enquoteContentsIfNeeded(cell:)) 675 | .joined(separator: separator) + "\n" 676 | 677 | let content = rows.map { row in 678 | header 679 | .map { cellID in row[cellID]! } 680 | .map(enquoteContentsIfNeeded(cell:)) 681 | .joined(separator: separator) 682 | }.joined(separator: "\n") 683 | 684 | return head + content 685 | } 686 | } 687 | 688 | // 689 | // Parser.swift 690 | // SwiftCSV 691 | // 692 | // Created by Will Richardson on 13/04/16. 693 | // Copyright © 2016 Naoto Kaneko. All rights reserved. 694 | // 695 | 696 | extension CSV { 697 | /// Parse the file and call a block on each row, passing it in as a list of fields. 698 | /// - Parameters limitTo: Maximum absolute line number in the content, *not* maximum amount of rows. 699 | @available(*, deprecated, message: "Use enumerateAsArray(startAt:rowLimit:_:) instead") 700 | public func enumerateAsArray(limitTo maxRow: Int? = nil, startAt: Int = 0, _ rowCallback: @escaping ([String]) -> Void) throws { 701 | try Parser.enumerateAsArray(text: text, delimiter: delimiter, startAt: startAt, rowLimit: maxRow.map { $0 - startAt }, rowCallback: rowCallback) 702 | } 703 | 704 | /// Parse the CSV contents row by row from `start` for `rowLimit` amount of rows, or until the end of the input. 705 | /// - Parameters: 706 | /// - startAt: Skip lines before this. Default value is `0` to start at the beginning. 707 | /// - rowLimit: Amount of rows to consume, beginning to count at `startAt`. Default value is `nil` to consume 708 | /// the whole input string. 709 | /// - rowCallback: Array of each row's columnar values, in order. 710 | public func enumerateAsArray(startAt: Int = 0, rowLimit: Int? = nil, _ rowCallback: @escaping ([String]) -> Void) throws { 711 | try Parser.enumerateAsArray(text: text, delimiter: delimiter, startAt: startAt, rowLimit: rowLimit, rowCallback: rowCallback) 712 | } 713 | 714 | public func enumerateAsDict(_ block: @escaping ([String: String]) -> Void) throws { 715 | try Parser.enumerateAsDict(header: header, content: text, delimiter: delimiter, block: block) 716 | } 717 | } 718 | 719 | enum Parser { 720 | static func array(text: String, delimiter: CSVDelimiter, startAt offset: Int = 0, rowLimit: Int? = nil) throws -> [[String]] { 721 | var rows = [[String]]() 722 | 723 | try enumerateAsArray(text: text, delimiter: delimiter, startAt: offset, rowLimit: rowLimit) { row in 724 | rows.append(row) 725 | } 726 | 727 | return rows 728 | } 729 | 730 | /// Parse `text` and provide each row to `rowCallback` as an array of field values, one for each column per 731 | /// line of text, separated by `delimiter`. 732 | /// 733 | /// - Parameters: 734 | /// - text: Text to parse. 735 | /// - delimiter: Character to split row and header fields by (default is ',') 736 | /// - offset: Skip lines before this. Default value is `0` to start at the beginning. 737 | /// - rowLimit: Amount of rows to consume, beginning to count at `startAt`. Default value is `nil` to consume 738 | /// the whole input string. 739 | /// - rowCallback: Callback invoked for every parsed row between `startAt` and `limitTo` in `text`. 740 | /// - Throws: `CSVParseError` 741 | static func enumerateAsArray(text: String, 742 | delimiter: CSVDelimiter, 743 | startAt offset: Int = 0, 744 | rowLimit: Int? = nil, 745 | rowCallback: @escaping ([String]) -> Void) throws 746 | { 747 | let maxRowIndex = rowLimit.flatMap { $0 < 0 ? nil : offset + $0 } 748 | 749 | var currentIndex = text.startIndex 750 | let endIndex = text.endIndex 751 | 752 | var fields = [String]() 753 | let delimiter = delimiter.rawValue 754 | var field = "" 755 | 756 | var rowIndex = 0 757 | 758 | func finishRow() { 759 | defer { 760 | rowIndex += 1 761 | fields = [] 762 | field = "" 763 | } 764 | 765 | guard rowIndex >= offset else { return } 766 | fields.append(String(field)) 767 | rowCallback(fields) 768 | } 769 | 770 | var state = ParsingState( 771 | delimiter: delimiter, 772 | finishRow: finishRow, 773 | appendChar: { 774 | guard rowIndex >= offset else { return } 775 | field.append($0) 776 | }, 777 | finishField: { 778 | guard rowIndex >= offset else { return } 779 | fields.append(field) 780 | field = "" 781 | } 782 | ) 783 | 784 | func limitReached(_ rowNumber: Int) -> Bool { 785 | guard let maxRowIndex else { return false } 786 | return rowNumber >= maxRowIndex 787 | } 788 | 789 | while currentIndex < endIndex, 790 | !limitReached(rowIndex) 791 | { 792 | let char = text[currentIndex] 793 | 794 | try state.change(char) 795 | 796 | currentIndex = text.index(after: currentIndex) 797 | } 798 | 799 | // Append remainder of the cache, unless we're past the limit already. 800 | if !limitReached(rowIndex) { 801 | if !field.isEmpty { 802 | fields.append(field) 803 | } 804 | 805 | if !fields.isEmpty { 806 | rowCallback(fields) 807 | } 808 | } 809 | } 810 | 811 | static func enumerateAsDict(header: [String], content: String, delimiter: CSVDelimiter, rowLimit: Int? = nil, block: @escaping ([String: String]) -> Void) throws { 812 | let enumeratedHeader = header.enumerated() 813 | 814 | // Start after the header 815 | try enumerateAsArray(text: content, delimiter: delimiter, startAt: 1, rowLimit: rowLimit) { fields in 816 | var dict = [String: String]() 817 | for (index, head) in enumeratedHeader { 818 | dict[head] = index < fields.count ? fields[index] : "" 819 | } 820 | block(dict) 821 | } 822 | } 823 | } 824 | 825 | // 826 | // ParsingState.swift 827 | // SwiftCSV 828 | // 829 | // Created by Christian Tietze on 25/10/16. 830 | // Copyright © 2016 Naoto Kaneko. All rights reserved. 831 | // 832 | 833 | public enum CSVParseError: Error { 834 | case generic(message: String) 835 | case quotation(message: String) 836 | } 837 | 838 | /// State machine of parsing CSV contents character by character. 839 | struct ParsingState { 840 | private(set) var atStart = true 841 | private(set) var parsingField = false 842 | private(set) var parsingQuotes = false 843 | private(set) var innerQuotes = false 844 | 845 | let delimiter: Character 846 | let finishRow: () -> Void 847 | let appendChar: (Character) -> Void 848 | let finishField: () -> Void 849 | 850 | init(delimiter: Character, 851 | finishRow: @escaping () -> Void, 852 | appendChar: @escaping (Character) -> Void, 853 | finishField: @escaping () -> Void) 854 | { 855 | self.delimiter = delimiter 856 | self.finishRow = finishRow 857 | self.appendChar = appendChar 858 | self.finishField = finishField 859 | } 860 | 861 | /// - Throws: `CSVParseError` 862 | mutating func change(_ char: Character) throws { 863 | if atStart { 864 | if char == "\"" { 865 | atStart = false 866 | parsingQuotes = true 867 | } else if char == delimiter { 868 | finishField() 869 | } else if char.isNewline { 870 | finishRow() 871 | } else if char.isWhitespace { 872 | // ignore whitespaces between fields 873 | } else { 874 | parsingField = true 875 | atStart = false 876 | appendChar(char) 877 | } 878 | } else if parsingField { 879 | if innerQuotes { 880 | if char == "\"" { 881 | appendChar(char) 882 | innerQuotes = false 883 | } else { 884 | throw CSVParseError.quotation(message: "Can't have non-quote here: \(char)") 885 | } 886 | } else { 887 | if char == "\"" { 888 | innerQuotes = true 889 | } else if char == delimiter { 890 | atStart = true 891 | parsingField = false 892 | innerQuotes = false 893 | finishField() 894 | } else if char.isNewline { 895 | atStart = true 896 | parsingField = false 897 | innerQuotes = false 898 | finishRow() 899 | } else { 900 | appendChar(char) 901 | } 902 | } 903 | } else if parsingQuotes { 904 | if innerQuotes { 905 | if char == "\"" { 906 | appendChar(char) 907 | innerQuotes = false 908 | } else if char == delimiter { 909 | atStart = true 910 | parsingField = false 911 | innerQuotes = false 912 | finishField() 913 | } else if char.isNewline { 914 | atStart = true 915 | parsingQuotes = false 916 | innerQuotes = false 917 | finishRow() 918 | } else if char.isWhitespace { 919 | // ignore whitespaces between fields 920 | } else { 921 | throw CSVParseError.quotation(message: "Can't have non-quote here: \(char)") 922 | } 923 | } else { 924 | if char == "\"" { 925 | innerQuotes = true 926 | } else { 927 | appendChar(char) 928 | } 929 | } 930 | } else { 931 | throw CSVParseError.generic(message: "me_irl") 932 | } 933 | } 934 | } 935 | 936 | // 937 | // String+Lines.swift 938 | // SwiftCSV 939 | // 940 | // Created by Naoto Kaneko on 2/24/16. 941 | // Copyright © 2016 Naoto Kaneko. All rights reserved. 942 | // 943 | 944 | extension String { 945 | var firstLine: String { 946 | var current = startIndex 947 | while current < endIndex, self[current].isNewline == false { 948 | current = index(after: current) 949 | } 950 | return String(self[.. 0 else { 139 | print("no candidates found!") 140 | exit(1) 141 | } 142 | 143 | let paddingLength = String(candidates.count).count 144 | for (idx, url) in candidates.enumerated() { 145 | print("[*] processing \(idx.paddedString(totalLength: paddingLength))/\(candidates.count) <\(url.lastPathComponent)>") 146 | fflush(stdout) 147 | autoreleasepool { 148 | rebuildTimeZone(imageFile: url, expectedOffsetString: expectedOffsetString, changeToOffset: changeToOffset) 149 | } 150 | } 151 | 152 | print("[*] completed update") 153 | 154 | // helpers 155 | 156 | extension Int { 157 | func paddedString(totalLength: Int) -> String { 158 | var str = String(self) 159 | while str.count < totalLength { 160 | str = "0" + str 161 | } 162 | return str 163 | } 164 | } 165 | --------------------------------------------------------------------------------