diff --git a/SwiftBeanCountApp/Settings/GeneralSettingsView.swift b/SwiftBeanCountApp/Settings/GeneralSettingsView.swift index 7859c7e..2647650 100644 --- a/SwiftBeanCountApp/Settings/GeneralSettingsView.swift +++ b/SwiftBeanCountApp/Settings/GeneralSettingsView.swift @@ -17,12 +17,20 @@ struct GeneralSettingsView: View { let payees: [String: String] let accounts: [String: String] let descriptions: [String: String] + let ignoredPayeeDuplicates: [IgnoredPayeeDuplicatePair] let dateTolerance: String - init(payees: [String: String], accounts: [String: String], descriptions: [String: String], dateTolerance: String) { + init( + payees: [String: String], + accounts: [String: String], + descriptions: [String: String], + ignoredPayeeDuplicates: [IgnoredPayeeDuplicatePair], + dateTolerance: String + ) { self.payees = payees self.accounts = accounts self.descriptions = descriptions + self.ignoredPayeeDuplicates = ignoredPayeeDuplicates self.dateTolerance = dateTolerance } @@ -143,6 +151,7 @@ struct GeneralSettingsView: View { for (description, account) in settingsFile.accounts { Settings.setAccountMapping(key: description, account: account) } + IgnoredPayeeDuplicateSettings.replaceAll(with: settingsFile.ignoredPayeeDuplicates) if let dateTolerance = Int(settingsFile.dateTolerance) { Settings.dateToleranceInDays = dateTolerance self.dateTolerance = dateTolerance @@ -153,6 +162,7 @@ struct GeneralSettingsView: View { SettingsFile(payees: Settings.allPayeeMappings, accounts: Settings.allAccountMappings, descriptions: Settings.allDescriptionMappings, + ignoredPayeeDuplicates: IgnoredPayeeDuplicateSettings.allPairs(), dateTolerance: "\(Settings.dateToleranceInDays)") } diff --git a/SwiftBeanCountApp/Settings/IgnoredPayeeDuplicateSettings.swift b/SwiftBeanCountApp/Settings/IgnoredPayeeDuplicateSettings.swift new file mode 100644 index 0000000..542ec29 --- /dev/null +++ b/SwiftBeanCountApp/Settings/IgnoredPayeeDuplicateSettings.swift @@ -0,0 +1,88 @@ +// +// IgnoredPayeeDuplicateSettings.swift +// SwiftBeanCountApp +// +// Created by Copilot on 2026-06-06. +// + +import Foundation + +struct IgnoredPayeeDuplicatePair: Codable, Hashable { + + let payee1: String + let payee2: String + + init(payee1: String, payee2: String) { + let trimmedPayee1 = payee1.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedPayee2 = payee2.trimmingCharacters(in: .whitespacesAndNewlines) + if Self.shouldSwap(trimmedPayee1, trimmedPayee2) { + self.payee1 = trimmedPayee2 + self.payee2 = trimmedPayee1 + } else { + self.payee1 = trimmedPayee1 + self.payee2 = trimmedPayee2 + } + } + + private static func shouldSwap(_ left: String, _ right: String) -> Bool { + // too keep consitent when changing locale, do not use localized compare + // Otherwise, after chaning locale, the order might change, so they would + // no longer match and show up incorrectly again + left > right + } +} + +enum IgnoredPayeeDuplicateSettings { + + private static let key = "ignoredPayeeDuplicates" + + static func allPairs() -> [IgnoredPayeeDuplicatePair] { + guard let data = UserDefaults.standard.data(forKey: key), + let pairs = try? JSONDecoder().decode([IgnoredPayeeDuplicatePair].self, from: data) else { + return [] + } + return normalized(pairs) + } + + static func allPairsSet() -> Set { + Set(allPairs()) + } + + static func add(_ pair: IgnoredPayeeDuplicatePair) { + var pairs = allPairsSet() + pairs.insert(pair) + save(Array(pairs)) + } + + static func remove(_ pair: IgnoredPayeeDuplicatePair) { + var pairs = allPairsSet() + pairs.remove(pair) + save(Array(pairs)) + } + + static func replaceAll(with pairs: [IgnoredPayeeDuplicatePair]) { + save(pairs) + } + + private static func save(_ pairs: [IgnoredPayeeDuplicatePair]) { + let normalizedPairs = normalized(pairs) + guard !normalizedPairs.isEmpty else { + UserDefaults.standard.removeObject(forKey: key) + return + } + guard let data = try? JSONEncoder().encode(normalizedPairs) else { + return + } + UserDefaults.standard.set(data, forKey: key) + } + + private static func normalized(_ pairs: [IgnoredPayeeDuplicatePair]) -> [IgnoredPayeeDuplicatePair] { + Array(Set(pairs.filter { !$0.payee1.isEmpty && !$0.payee2.isEmpty })) + .sorted { + if $0.payee1 == $1.payee1 { + return $0.payee2.localizedCaseInsensitiveCompare($1.payee2) == .orderedAscending + } + return $0.payee1.localizedCaseInsensitiveCompare($1.payee1) == .orderedAscending + } + } +} diff --git a/SwiftBeanCountApp/Settings/SettingsTableView.swift b/SwiftBeanCountApp/Settings/SettingsTableView.swift index 9aec466..341bb0c 100644 --- a/SwiftBeanCountApp/Settings/SettingsTableView.swift +++ b/SwiftBeanCountApp/Settings/SettingsTableView.swift @@ -79,12 +79,17 @@ struct SettingsTableView: View { .contextMenu(forSelectionType: T.ID.self) { ids in let items = ids.map { id in allData.first { $0.id == id } } if let item = items.first { - Button("Edit") { edit(item!) }.keyboardShortcut(.defaultAction) + if T.isEditable { + Button("Edit") { edit(item!) }.keyboardShortcut(.defaultAction) + } Button("Delete", role: .destructive) { delete(item!.id) }.keyboardShortcut(.delete) } else { EmptyView() } } primaryAction: { ids in + guard T.isEditable else { + return + } let items = ids.map { id in allData.first { $0.id == id } } if let item = items.first { edit(item!) @@ -108,6 +113,9 @@ struct SettingsTableView: View { } #endif .onKeyPress(.return) { + guard T.isEditable else { + return .ignored + } if let editing, editing == selected { endEditing() return .handled diff --git a/SwiftBeanCountApp/Settings/SettingsTableViewDataSource.swift b/SwiftBeanCountApp/Settings/SettingsTableViewDataSource.swift index 497f769..cc757fb 100644 --- a/SwiftBeanCountApp/Settings/SettingsTableViewDataSource.swift +++ b/SwiftBeanCountApp/Settings/SettingsTableViewDataSource.swift @@ -11,6 +11,7 @@ import SwiftUI protocol SettingsTableViewDataSource: Identifiable { // swiftlint:disable:this file_types_order static var keyName: String { get } static var hasValue2: Bool { get } + static var isEditable: Bool { get } static var value1Name: String { get } static var value2Name: String { get } @@ -100,3 +101,38 @@ struct PayeeAccountMapping: SettingsTableViewDataSource { Settings.setAccountMapping(key: key, account: nil) } } + +struct IgnoredPayeeDuplicateMapping: SettingsTableViewDataSource { + static var keyName: String { "Payee 1" } + static var hasValue2: Bool { false } + static var isEditable: Bool { false } + static var value1Name: String { "Payee 2" } + static var value2Name: String { "" } + + let id = UUID() + let key: String + let payee2: String + + var value1: String { payee2 } + var value2: String { "" } + + static func load() -> [Self] { + IgnoredPayeeDuplicateSettings.allPairs().map { Self(key: $0.payee1, payee2: $0.payee2) } + } + + func setValue1(_: String) { + // Not editable - ignored duplicate pairs can only be removed. + } + + func setValue2(_: String) { + // Not editable - ignored duplicate pairs can only be removed. + } + + func delete() { + IgnoredPayeeDuplicateSettings.remove(IgnoredPayeeDuplicatePair(payee1: key, payee2: payee2)) + } +} + +extension SettingsTableViewDataSource { + static var isEditable: Bool { true } +} diff --git a/SwiftBeanCountApp/Settings/SettingsView.swift b/SwiftBeanCountApp/Settings/SettingsView.swift index a327472..2235933 100644 --- a/SwiftBeanCountApp/Settings/SettingsView.swift +++ b/SwiftBeanCountApp/Settings/SettingsView.swift @@ -14,31 +14,24 @@ struct SettingsView: View { #if os(macOS) TabView { SwiftUI.Tab("General", systemImage: "gear") { - HStack { - VStack { - GeneralSettingsView() - Spacer() - } - Spacer() - }.padding() + settingsContainer { + GeneralSettingsView() + } } SwiftUI.Tab("Description Mapping", image: "DescriptionMapping") { - HStack { - VStack { - SettingsTableView() - Spacer() - } - Spacer() - }.padding() + settingsContainer { + SettingsTableView() + } } SwiftUI.Tab("Account Mapping", image: "AccountMapping") { - HStack { - VStack { - SettingsTableView() - Spacer() - } - Spacer() - }.padding() + settingsContainer { + SettingsTableView() + } + } + SwiftUI.Tab("Ignored Duplicates", systemImage: "xmark.circle") { + settingsContainer { + SettingsTableView() + } } } .frame(minWidth: 900, minHeight: 500) @@ -60,6 +53,11 @@ struct SettingsView: View { } label: { Text("Account Mapping") } + NavigationLink { + SettingsTableView().padding() + } label: { + Text("Ignored Duplicates") + } } .navigationTitle("Settings") } detail: { @@ -68,6 +66,18 @@ struct SettingsView: View { #endif } + @ViewBuilder + private func settingsContainer(@ViewBuilder content: () -> Content) -> some View { + HStack { + VStack { + content() + Spacer() + } + Spacer() + } + .padding() + } + } #Preview { diff --git a/SwiftBeanCountApp/Tabs/Payees.swift b/SwiftBeanCountApp/Tabs/Payees.swift index 80e9d3f..bcd97ec 100644 --- a/SwiftBeanCountApp/Tabs/Payees.swift +++ b/SwiftBeanCountApp/Tabs/Payees.swift @@ -135,26 +135,37 @@ struct Payees: View { private var duplicateList: some View { List(duplicates) { duplicate in - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(duplicate.payee1).bold() - Text("\(duplicate.countPayee1)") - Text("↔").foregroundColor(.secondary) - Text(duplicate.payee2).bold() - Text("\(duplicate.countPayee2)") - } - HStack { - Text(duplicate.reason) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text("Confidence: \(Int(duplicate.confidence * 100))%") - .font(.caption) - .foregroundColor(confidenceColor(duplicate.confidence)) + duplicateView(duplicate) + } + } + + func duplicateView(_ duplicate: PayeeDuplicate) -> some View { + VStack(alignment: .leading) { + HStack { + Text(duplicate.payee1).bold() + Text("(\(duplicate.countPayee1))") + Text("↔").foregroundColor(.secondary) + Text(duplicate.payee2).bold() + Text("(\(duplicate.countPayee2))") + Spacer() + Button("Not a Duplicate") { + markAsNotDuplicate(duplicate) } + .buttonStyle(.bordered) + .controlSize(.small) + } + HStack { + Text(duplicate.reason) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("Confidence: \(Int(duplicate.confidence * 100))%") + .font(.caption) + .foregroundColor(confidenceColor(duplicate.confidence)) + .padding(.trailing, 8) } - .padding(.vertical, 2) } + .padding(.vertical, 2) } private func confidenceColor(_ confidence: Double) -> Color { @@ -198,8 +209,21 @@ struct Payees: View { } } + private func markAsNotDuplicate(_ duplicate: PayeeDuplicate) { + let ignoredPair = IgnoredPayeeDuplicatePair(payee1: duplicate.payee1, payee2: duplicate.payee2) + IgnoredPayeeDuplicateSettings.add(ignoredPair) + duplicates.removeAll { + IgnoredPayeeDuplicatePair(payee1: $0.payee1, payee2: $0.payee2) == ignoredPair + } + } + } #Preview { Payees().environmentObject(LedgerManager(URL(fileURLWithPath: "/Users/User/Download/Test.beancount"))) } + +#Preview { + Payees().duplicateView(PayeeDuplicate(payee1: "Test Sushi", countPayee1: 4, payee2: "Tes Sushi", countPayee2: 3, confidence: 0.4, reason: "1 character difference")) + .padding() +} diff --git a/SwiftBeanCountApp/Tabs/Payees/PayeeDuplicateDetector.swift b/SwiftBeanCountApp/Tabs/Payees/PayeeDuplicateDetector.swift index 6e911f7..959956d 100644 --- a/SwiftBeanCountApp/Tabs/Payees/PayeeDuplicateDetector.swift +++ b/SwiftBeanCountApp/Tabs/Payees/PayeeDuplicateDetector.swift @@ -37,14 +37,14 @@ enum PayeeDuplicateDetector { counts[payee, default: 0] += 1 } let sortedCounts = counts.sorted { $0.key.lowercased() < $1.key.lowercased() }.map { ($0.key, $0.value) } - let duplicates = Self.findDuplicates(in: counts) + let duplicates = Self.findDuplicates(in: counts, ignoring: IgnoredPayeeDuplicateSettings.allPairsSet()) return (sortedCounts, duplicates) } /// Finds potential duplicate payees from a list of payee names /// - Parameter payees: Array of payee names /// - Returns: Array of potential duplicates sorted by confidence (highest first) - private static func findDuplicates(in payees: [String: Int]) -> [PayeeDuplicate] { + private static func findDuplicates(in payees: [String: Int], ignoring ignoredPairs: Set) -> [PayeeDuplicate] { var duplicates = [PayeeDuplicate]() let payeeList = payees.map(\.0).sorted() @@ -52,6 +52,9 @@ enum PayeeDuplicateDetector { for j in (i + 1)..