Skip to content
Open
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
4 changes: 4 additions & 0 deletions PayForMe.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -88,6 +89,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
69E6AED117C0505EF274EA0B /* ProjectIdentifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectIdentifierTests.swift; sourceTree = "<group>"; };
0865D2DAF541B24078D1DC41 /* TestHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
143F41EE78329905894C8A29 /* BalanceCalculationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BalanceCalculationTests.swift; sourceTree = "<group>"; };
316FBA7C0CE4ABB51EC2A121 /* NetworkRequestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkRequestTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -228,6 +230,7 @@
isa = PBXGroup;
children = (
654754E32528B29F00A82EB6 /* AddProjectManuallyTests.swift */,
69E6AED117C0505EF274EA0B /* ProjectIdentifierTests.swift */,
654754E52528B29F00A82EB6 /* Info.plist */,
6523FC4B25580EEF00BCD843 /* UrlExtensionsTests.swift */,
0865D2DAF541B24078D1DC41 /* TestHelpers.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
16 changes: 16 additions & 0 deletions PayForMe/Model/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions PayForMe/Util/Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
79 changes: 79 additions & 0 deletions PayForMeTests/ProjectIdentifierTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}