Title-case station names: fix Sainsbury's apostrophe, lowercase/mixed names, hyphen/paren chunks, acronyms; re-sanitize cache on load

This commit is contained in:
FuelBoard Contributor
2026-08-11 21:23:36 +01:00
parent 7be98c51ad
commit aee890e2a8
+56 -10
View File
@@ -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<String> = ["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<String> = ["BP", "MFG", "ASDA", "MOTO", "SPAR", "UK", "NI", "SS"]
let keepLowercase: Set<String> = ["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)