From aee890e2a89a8a412e85136a56a0e40e69eba2e4 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Tue, 11 Aug 2026 21:23:36 +0100 Subject: [PATCH] Title-case station names: fix Sainsbury's apostrophe, lowercase/mixed names, hyphen/paren chunks, acronyms; re-sanitize cache on load --- Shared/FuelStore.swift | 66 +++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 076469f..7a55bb6 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -119,17 +119,55 @@ struct FuelStation: Identifiable, Codable, Equatable { } extension String { - /// Word-capitalises ALL-CAPS titles ("SHELL SALTERHEBBLE" → "Shell Salterhebble"), - /// preserving known acronyms (BP, MFG, ASDA). Mixed-case names pass through untouched. + /// Word-capitalises station titles to title case: + /// "SHELL SALTERHEBBLE" → "Shell Salterhebble" + /// "SAINSBURYS HALIFAX" → "Sainsbury's Halifax" + /// "birmingham road service station" → "Birmingham Road Service Station" + /// Known acronyms (BP, MFG, ASDA, MOTO, SPAR, UK, NI…) stay uppercase; short + /// all-caps tokens in mixed names are treated as initials (TJ, WR, SJS…); + /// connectors (and/of/the/on/ta/t-a) stay lowercase; "LTD" → "Ltd"; + /// apostrophes keep their chunk together; hyphens/parens/& reset a chunk. var sanitizedStationTitle: String { - guard self == self.uppercased(), self.rangeOfCharacter(from: .letters) != nil else { return self } - let keepUppercase: Set = ["BP", "MFG", "ASDA", "MOTO"] - return self.split(separator: " ").map { word in - let w = String(word) - if keepUppercase.contains(w.uppercased()) { return w.uppercased() } - return w.prefix(1).uppercased() + w.dropFirst().lowercased() + guard rangeOfCharacter(from: .letters) != nil else { return self } + let keepUppercase: Set = ["BP", "MFG", "ASDA", "MOTO", "SPAR", "UK", "NI", "SS"] + let keepLowercase: Set = ["of", "and", "the", "on", "ta", "t/a"] + let isAllCaps = self == self.uppercased() + return self.split(separator: " ").map { rawWord in + let w = String(rawWord) + if w.uppercased() == "SAINSBURYS" { return "Sainsbury's" } + if keepLowercase.contains(w.lowercased()) { return w.lowercased() } + let core = w.filter { $0.isLetter } + if keepUppercase.contains(core.uppercased()) { return w.uppercased() } + if core.uppercased() == "LTD" { return "Ltd" } + if !isAllCaps, w == w.uppercased(), (1...3).contains(core.count) { return w } + return w.capitalizedChunks }.joined(separator: " ") } + + /// Capitalises the first letter of each alpha-chunk and lowercases the rest. + /// Apostrophes do NOT reset the chunk ("Sainsbury's", "Adam's"); + /// hyphens, &, parens and dots do ("NEWCASTLE-UNDER-LYME" → "Newcastle-Under-Lyme", + /// "(MEADOWHALL" → "(Meadowhall"). + private var capitalizedChunks: String { + var out = "" + var newChunk = true + for ch in self { + if ch.isLetter { + if newChunk { + out.append(ch.uppercased()) + newChunk = false + } else { + out.append(ch.lowercased()) + } + } else if ch == "'" { + out.append(ch) + } else { + out.append(ch) + newChunk = true + } + } + return out + } } // MARK: - Shared store @@ -157,16 +195,24 @@ struct FuelStore { let data = defaults.data(forKey: stationsKey), let stations = try? JSONDecoder().decode([FuelStation].self, from: data), !stations.isEmpty { - return stations + return stations.map(sanitized) } if let data = keychainData(service: stationsKey), let stations = try? JSONDecoder().decode([FuelStation].self, from: data), !stations.isEmpty { - return stations + return stations.map(sanitized) } return [] } + /// Re-runs the title-case sanitizer on cached stations so names fixed by + /// newer sanitizer logic appear without waiting for the next fetch. + private static func sanitized(_ station: FuelStation) -> FuelStation { + var s = station + s.name = station.name.sanitizedStationTitle + return s + } + static func saveStations(_ stations: [FuelStation]) { if let data = try? JSONEncoder().encode(stations) { UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey)