Skip to content
Merged
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
12 changes: 11 additions & 1 deletion SwiftBeanCountApp/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -153,6 +162,7 @@ struct GeneralSettingsView: View {
SettingsFile(payees: Settings.allPayeeMappings,
accounts: Settings.allAccountMappings,
descriptions: Settings.allDescriptionMappings,
ignoredPayeeDuplicates: IgnoredPayeeDuplicateSettings.allPairs(),
dateTolerance: "\(Settings.dateToleranceInDays)")
}

Expand Down
88 changes: 88 additions & 0 deletions SwiftBeanCountApp/Settings/IgnoredPayeeDuplicateSettings.swift
Original file line number Diff line number Diff line change
@@ -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<IgnoredPayeeDuplicatePair> {
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
}
}
}
10 changes: 9 additions & 1 deletion SwiftBeanCountApp/Settings/SettingsTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,17 @@ struct SettingsTableView<T: SettingsTableViewDataSource>: 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!)
Expand All @@ -108,6 +113,9 @@ struct SettingsTableView<T: SettingsTableViewDataSource>: View {
}
#endif
.onKeyPress(.return) {
guard T.isEditable else {
return .ignored
}
if let editing, editing == selected {
endEditing()
return .handled
Expand Down
36 changes: 36 additions & 0 deletions SwiftBeanCountApp/Settings/SettingsTableViewDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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 }
}
52 changes: 31 additions & 21 deletions SwiftBeanCountApp/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<DescriptionPayeeMapping>()
Spacer()
}
Spacer()
}.padding()
settingsContainer {
SettingsTableView<DescriptionPayeeMapping>()
}
}
SwiftUI.Tab("Account Mapping", image: "AccountMapping") {
HStack {
VStack {
SettingsTableView<PayeeAccountMapping>()
Spacer()
}
Spacer()
}.padding()
settingsContainer {
SettingsTableView<PayeeAccountMapping>()
}
}
SwiftUI.Tab("Ignored Duplicates", systemImage: "xmark.circle") {
settingsContainer {
SettingsTableView<IgnoredPayeeDuplicateMapping>()
}
}
}
.frame(minWidth: 900, minHeight: 500)
Expand All @@ -60,6 +53,11 @@ struct SettingsView: View {
} label: {
Text("Account Mapping")
}
NavigationLink {
SettingsTableView<IgnoredPayeeDuplicateMapping>().padding()
} label: {
Text("Ignored Duplicates")
}
}
.navigationTitle("Settings")
} detail: {
Expand All @@ -68,6 +66,18 @@ struct SettingsView: View {
#endif
}

@ViewBuilder
private func settingsContainer<Content: View>(@ViewBuilder content: () -> Content) -> some View {
HStack {
VStack {
content()
Spacer()
}
Spacer()
}
.padding()
}

}

#Preview {
Expand Down
58 changes: 41 additions & 17 deletions SwiftBeanCountApp/Tabs/Payees.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
7 changes: 5 additions & 2 deletions SwiftBeanCountApp/Tabs/Payees/PayeeDuplicateDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,24 @@ 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<IgnoredPayeeDuplicatePair>) -> [PayeeDuplicate] {
var duplicates = [PayeeDuplicate]()
let payeeList = payees.map(\.0).sorted()

for i in 0..<payeeList.count {
for j in (i + 1)..<payeeList.count {
let payee1 = payeeList[i]
let payee2 = payeeList[j]
guard !ignoredPairs.contains(IgnoredPayeeDuplicatePair(payee1: payee1, payee2: payee2)) else {
continue
}

if let (confidence, reason) = detectDuplicate(payee1, payee2) {
let count1 = payees[payee1] ?? 0
Expand Down
Loading