Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions Sources/Gedcom/GedcomAge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// SPDX-License-Identifier: Apache-2.0
//
// Copyright 2026 Mattias Holm
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

public enum GedcomAgeBound: String, Codable, Equatable {
case lessThan = "<"
case greaterThan = ">"
}

public struct GedcomAgeDuration: Codable, Equatable {
public let years: Int?
public let months: Int?
public let weeks: Int?
public let days: Int?

public init(years: Int? = nil, months: Int? = nil, weeks: Int? = nil, days: Int? = nil) {
self.years = years
self.months = months
self.weeks = weeks
self.days = days
}
}

public struct GedcomAge: Codable, Equatable {
public let bound: GedcomAgeBound?
public let duration: GedcomAgeDuration?

public init(bound: GedcomAgeBound? = nil, duration: GedcomAgeDuration? = nil) {
self.bound = bound
self.duration = duration
}

public var isEmpty: Bool {
bound == nil && duration == nil
}
}

public enum GedcomAgeParser {
public static func parse(_ raw: String) -> GedcomAge? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return GedcomAge()
}

var tokens = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
var bound: GedcomAgeBound?

if let first = tokens.first, let parsedBound = GedcomAgeBound(rawValue: first) {
bound = parsedBound
tokens.removeFirst()
}

guard !tokens.isEmpty else { return nil }

var years: Int?
var months: Int?
var weeks: Int?
var days: Int?
var lastUnitOrder = 0

for token in tokens {
guard let unit = token.last else { return nil }
let valueText = token.dropLast()
guard !valueText.isEmpty, valueText.allSatisfy({ $0 >= "0" && $0 <= "9" }), let value = Int(valueText) else {
return nil
}

let unitOrder: Int
switch unit {
case "y":
guard years == nil else { return nil }
years = value
unitOrder = 1
case "m":
guard months == nil else { return nil }
months = value
unitOrder = 2
case "w":
guard weeks == nil else { return nil }
weeks = value
unitOrder = 3
case "d":
guard days == nil else { return nil }
days = value
unitOrder = 4
default:
return nil
}

guard unitOrder > lastUnitOrder else { return nil }
lastUnitOrder = unitOrder
}

return GedcomAge(bound: bound, duration: GedcomAgeDuration(years: years, months: months, weeks: weeks, days: days))
}
}
19 changes: 0 additions & 19 deletions Sources/Gedcom/GedcomDate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,6 @@ public enum GedcomDateParser {
return .exact(d)
}

// INT handling? INT <Date> (<Phrase>)
if parts.first == "INT" {
// Fallback to phrase or simple parsing?
// INT 1800 (Eighteen Hundred)
// Parsing the date part might work.
let dateStr = parts.dropFirst().joined(separator: " ")
// This is tricky without lookahead for parens.
// For now, treat exact date as fallback
}

return nil
}

Expand Down Expand Up @@ -203,15 +193,6 @@ public enum GedcomDateParser {
if let y = Int(t) {
year = y
tokens.removeFirst()
} else if t.contains("/") {
// Dual date: 1699/00
// Store raw? or parse base year?
// SimpleDate struct stores int.
// Ignoring dual date nuance for `year: Int` simplification.
// Could verify if slash part exists.
if let slash = t.firstIndex(of: "/") {
year = Int(String(t[..<slash]))
}
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/Gedcom/Individual.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Foundation

public class Age : RecordProtocol {
public var age: String
public var parsedAge: GedcomAge? { GedcomAgeParser.parse(age) }
public var phrase: String?

nonisolated(unsafe) static let keys : [String:AnyKeyPath] = [
Expand Down
18 changes: 18 additions & 0 deletions Tests/GedcomTests/GedcomImprovementsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ import Testing
#expect(d5 == .range(start: GedcomSimpleDate(year: 1990), end: GedcomSimpleDate(year: 2000)))
}

@Test func testAgeParsing() {
#expect(GedcomAgeParser.parse("35y") == GedcomAge(duration: GedcomAgeDuration(years: 35)))
#expect(GedcomAgeParser.parse("< 2y 3m 4w 5d") == GedcomAge(bound: .lessThan, duration: GedcomAgeDuration(years: 2, months: 3, weeks: 4, days: 5)))
#expect(GedcomAgeParser.parse("> 8d") == GedcomAge(bound: .greaterThan, duration: GedcomAgeDuration(days: 8)))
#expect(GedcomAgeParser.parse("") == GedcomAge())
#expect(GedcomAgeParser.parse("1y 30m") == GedcomAge(duration: GedcomAgeDuration(years: 1, months: 30)))
#expect(GedcomAgeParser.parse("8w 30d") == GedcomAge(duration: GedcomAgeDuration(weeks: 8, days: 30)))
#expect(GedcomAgeParser.parse("2m 1y") == nil)
#expect(GedcomAgeParser.parse("1Y") == nil)
#expect(GedcomAgeParser.parse("<") == nil)
}

@Test func testAgeRecordParsedAge() {
let age = Age(age: "51w 6d", phrase: "363.5 days rounded down")
#expect(age.parsedAge == GedcomAge(duration: GedcomAgeDuration(weeks: 51, days: 6)))
#expect(age.phrase == "363.5 days rounded down")
}

/*
@Test func testNameSlashHandling() {
// This name has no slashes, so surname should be nil
Expand Down
Loading