diff --git a/PayForMe.xcodeproj/project.pbxproj b/PayForMe.xcodeproj/project.pbxproj index e5acab8..a9b5df3 100644 --- a/PayForMe.xcodeproj/project.pbxproj +++ b/PayForMe.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 88E8E4370F88039587CB3E85 /* ProjectIdentifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69E6AED117C0505EF274EA0B /* ProjectIdentifierTests.swift */; }; 481FB4FB23E964C8003BD108 /* ProjectManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481FB4FA23E964C8003BD108 /* ProjectManager.swift */; }; 481FB4FD23EAD78F003BD108 /* AddProjectManualViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481FB4FC23EAD78F003BD108 /* AddProjectManualViewModel.swift */; }; 489995DA23F6EC6F008B7E38 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489995D923F6EC6F008B7E38 /* OnboardingView.swift */; }; @@ -88,6 +89,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 69E6AED117C0505EF274EA0B /* ProjectIdentifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectIdentifierTests.swift; sourceTree = ""; }; 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; 143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BalanceCalculationTests.swift; sourceTree = ""; }; 316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkRequestTests.swift; sourceTree = ""; }; @@ -228,6 +230,7 @@ isa = PBXGroup; children = ( 654754E32528B29F00A82EB6 /* AddProjectManuallyTests.swift */, + 69E6AED117C0505EF274EA0B /* ProjectIdentifierTests.swift */, 654754E52528B29F00A82EB6 /* Info.plist */, 6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */, 0865D2DAF541B24078D1DC41 /* TestHelpers.swift */, @@ -544,6 +547,7 @@ buildActionMask = 2147483647; files = ( 654754E42528B29F00A82EB6 /* AddProjectManuallyTests.swift in Sources */, + 88E8E4370F88039587CB3E85 /* ProjectIdentifierTests.swift in Sources */, 6523FC4C25580EEF00BCD843 /* UrlExtensionsTests.swift in Sources */, 57BC8D25B91CE40F4669650C /* TestHelpers.swift in Sources */, D279295CFA25D9CC69C6F27C /* BillTests.swift in Sources */, diff --git a/PayForMe/Model/Project.swift b/PayForMe/Model/Project.swift index b1ed482..977bebb 100644 --- a/PayForMe/Model/Project.swift +++ b/PayForMe/Model/Project.swift @@ -97,6 +97,22 @@ enum ProjectBackend: Int, Codable { case cospend = 0 case iHateMoney = 1 + /// What the user typed into the add-project form, turned into the value the + /// API expects. + /// + /// iHateMoney addresses projects by an id derived from the name, and the id + /// is never shown in its web UI — so people type the name and the request + /// 404s. Cospend uses the share token exactly as entered and must not be + /// touched. + func projectIdentifier(fromUserInput input: String) -> String { + switch self { + case .iHateMoney: + return input.iHateMoneyProjectId + case .cospend: + return input + } + } + var staticPath: String { switch self { case .cospend: diff --git a/PayForMe/Util/Util.swift b/PayForMe/Util/Util.swift index 2ebe363..9e1db6a 100644 --- a/PayForMe/Util/Util.swift +++ b/PayForMe/Util/Util.swift @@ -57,6 +57,50 @@ extension String { return false } + /// The project id iHateMoney derives from a project's name: NFKD-normalised, + /// stripped of everything that is not a word character, whitespace or a + /// hyphen, lowercased, with runs of hyphens and whitespace collapsed into a + /// single hyphen. + /// + /// Users only ever see the name — the id is not shown anywhere in the web UI + /// — but the API is addressed by the id. A name typed into the add-project + /// form therefore has to be converted the same way the server converts it. + /// Mirrors `slugify()` in `ihatemoney/utils.py`. + var iHateMoneyProjectId: String { + let withoutPunctuation = decomposedStringWithCompatibilityMapping.unicodeScalars.filter { scalar in + switch scalar.properties.generalCategory { + case .nonspacingMark, .spacingMark, .enclosingMark: + // The diacritics NFKD just split off. Dropping them is what + // turns "Café" into "cafe" rather than "cafe\u{301}". + return false + default: + return CharacterSet.alphanumerics.contains(scalar) + || scalar == "_" + || scalar == "-" + || CharacterSet.whitespacesAndNewlines.contains(scalar) + } + } + + let normalised = String(String.UnicodeScalarView(withoutPunctuation)) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + var result = "" + var lastWasSeparator = false + for character in normalised { + if character == "-" || character.isWhitespace { + if !lastWasSeparator { + result.append("-") + } + lastWasSeparator = true + } else { + result.append(character) + lastWasSeparator = false + } + } + return result + } + var isValidEmail: Bool { // here, `try!` will always succeed because the pattern is valid let regex = try! NSRegularExpression(pattern: "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", options: .caseInsensitive) diff --git a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift index d347d4c..97e1cc6 100644 --- a/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift +++ b/PayForMe/Views/Projects/Manual/AddProjectManualViewModel.swift @@ -299,7 +299,11 @@ class AddProjectManualViewModel: ObservableObject { guard !password.isEmpty else { return nil } effectivePassword = password } - return Project(name: token, password: effectivePassword, token: token, backend: server.0, url: url, projectId: self.projectName) + // iHateMoney is addressed by the project id, which it derives + // from the name. The name is all users ever see, so accept it + // and convert it the same way the server does. + let identifier = server.0.projectIdentifier(fromUserInput: token) + return Project(name: token, password: effectivePassword, token: identifier, backend: server.0, url: url, projectId: identifier) } .removeDuplicates() .share() diff --git a/PayForMeTests/ProjectIdentifierTests.swift b/PayForMeTests/ProjectIdentifierTests.swift new file mode 100644 index 0000000..b3c93c4 --- /dev/null +++ b/PayForMeTests/ProjectIdentifierTests.swift @@ -0,0 +1,79 @@ +// +// ProjectIdentifierTests.swift +// PayForMeTests +// +// iHateMoney addresses a project by an id it derives from the project's name. +// That id is not shown anywhere in its web UI, so people type the name they +// see, the request 404s, and the project stays blank — the subject of #53. +// +// The expectations below mirror `slugify()` in `ihatemoney/utils.py`: +// +// value = unicodedata.normalize("NFKD", value) +// value = str(re.sub(r"[^\w\s-]", "", value).strip().lower()) +// return re.sub(r"[-\s]+", "-", value) +// + +import XCTest +@testable import PayForMe + +final class ProjectIdentifierTests: XCTestCase { + + // MARK: The cases from the issue + + /// "if I choose to enter in the form id: spongebob-house […] the bills and + /// users will load with name: Spongebob-house but not with: Spongebob house" + func testASpaceBecomesAHyphen() { + XCTAssertEqual("Spongebob house".iHateMoneyProjectId, "spongebob-house") + } + + /// "special characters are omitted from project ids […] + /// `something + somethingelse` -> `something-somethingelse`" + func testPunctuationIsDroppedAndTheGapCollapses() { + XCTAssertEqual("something + somethingelse".iHateMoneyProjectId, + "something-somethingelse") + } + + // MARK: The rules + + func testAnIdThatIsAlreadyCorrectIsUnchanged() { + XCTAssertEqual("spongebob-house".iHateMoneyProjectId, "spongebob-house", + "users who do enter the real id must not be broken") + } + + func testAccentsAreFolded() { + XCTAssertEqual("Café Ausflug".iHateMoneyProjectId, "cafe-ausflug") + } + + func testRunsOfSeparatorsCollapse() { + XCTAssertEqual("Trip 2024".iHateMoneyProjectId, "trip-2024") + XCTAssertEqual("a -- b".iHateMoneyProjectId, "a-b") + } + + func testSurroundingWhitespaceIsTrimmed() { + XCTAssertEqual(" Urlaub ".iHateMoneyProjectId, "urlaub") + } + + /// An underscore is a word character in the pattern and survives. + func testUnderscoresSurvive() { + XCTAssertEqual("Wohnung_WG".iHateMoneyProjectId, "wohnung_wg") + } + + func testAStringWithNothingUsableBecomesEmpty() { + XCTAssertEqual("!?!".iHateMoneyProjectId, "") + XCTAssertEqual("".iHateMoneyProjectId, "") + } + + // MARK: Backend dispatch + + func testIHateMoneyInputIsNormalised() { + XCTAssertEqual(ProjectBackend.iHateMoney.projectIdentifier(fromUserInput: "Spongebob house"), + "spongebob-house") + } + + /// Cospend addresses projects by a share token, which is case-sensitive and + /// must survive untouched — normalising it would break every Cospend project. + func testCospendInputIsUntouched() { + let token = "9dA50e410157DC1ca63e594af022f3a2" + XCTAssertEqual(ProjectBackend.cospend.projectIdentifier(fromUserInput: token), token) + } +}