From 56ffb7177afa432c8095590328444e019ec2d342 Mon Sep 17 00:00:00 2001 From: Mattias Holm Date: Sat, 20 Jun 2026 07:56:16 +0200 Subject: [PATCH] Add age parsing Also aligns gedcom dates with gedcom 7. I.e. no dual dating, no INT handling. --- Sources/Gedcom/GedcomAge.swift | 110 ++++++++++++++++++ Sources/Gedcom/GedcomDate.swift | 19 --- Sources/Gedcom/Individual.swift | 1 + .../GedcomTests/GedcomImprovementsTests.swift | 18 +++ 4 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 Sources/Gedcom/GedcomAge.swift diff --git a/Sources/Gedcom/GedcomAge.swift b/Sources/Gedcom/GedcomAge.swift new file mode 100644 index 0000000..6f6390e --- /dev/null +++ b/Sources/Gedcom/GedcomAge.swift @@ -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)) + } +} diff --git a/Sources/Gedcom/GedcomDate.swift b/Sources/Gedcom/GedcomDate.swift index a7c990c..57ce57c 100644 --- a/Sources/Gedcom/GedcomDate.swift +++ b/Sources/Gedcom/GedcomDate.swift @@ -122,16 +122,6 @@ public enum GedcomDateParser { return .exact(d) } - // INT handling? INT () - 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 } @@ -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[.. 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