CutBox.app

Coverage Report

Created: 2024-03-12 03:40

.../Source/App/Services/History/HistoryService.swift
Line
Count
Source (jump to first uncovered line)
1
//
2
//  HistoryService.swift
3
//  CutBox
4
//
5
//  Created by Jason Milkins on 24/3/18.
6
//  Copyright © 2018-2023 ocodo. All rights reserved.
7
//
8
9
import SwiftyStringScore
10
import RxSwift
11
12
protocol PasteboardWrapperType {
13
    var pasteboardItems: [NSPasteboardItem]? { get }
14
    func clearContents()
15
    func setString(string: String)
16
}
17
18
class PasteboardWrapper: PasteboardWrapperType {
19
41
    var pasteboardItems: [NSPasteboardItem]? {
20
41
        return NSPasteboard.general.pasteboardItems
21
41
    }
22
23
0
    func clearContents() {
24
0
        NSPasteboard.general.clearContents()
25
0
    }
26
27
0
    func setString(string: String) {
28
0
        NSPasteboard.general.setString(string, forType: .string)
29
0
    }
30
}
31
32
enum HistoryServiceEvents {
33
    case didSaveDefaults
34
    case didLoadDefaults
35
    case didClearHistory
36
}
37
38
class HistoryService {
39
40
    static let shared = HistoryService()
41
42
83
    let events = PublishSubject<HistoryServiceEvents>()
43
44
    /// Data store wrapper
45
    var historyRepo: HistoryRepo!
46
47
    var internalHistoryLimit: Int = 0
48
    var historyLimit: Int {
49
339
        get {
50
339
            return internalHistoryLimit
51
339
        }
52
4
        set {
53
4
            internalHistoryLimit = newValue
54
4
            self.truncateItems()
55
4
        }
56
    }
57
58
83
    let disposeBag = DisposeBag()
59
60
    var defaults: UserDefaults
61
    var pasteboard: PasteboardWrapperType
62
    var pollingTimer: Timer?
63
    var filterText: String? {
64
32
        didSet {
65
32
            invalidateCache()
66
32
        }
67
    }
68
69
    var removeGuard: String?
70
71
83
    private var internalDefaultSearchmode: HistorySearchMode = .fuzzyMatch
72
73
    var searchMode: HistorySearchMode {
74
128
        get {
75
128
            if let rawValue = self.defaults.string(forKey: searchModeKey) {
76
23
                return HistorySearchMode.searchMode(from: rawValue)
77
105
            }
78
105
            return internalDefaultSearchmode
79
128
        }
80
15
        set {
81
15
            self.defaults.set(newValue.rawValue, forKey: searchModeKey)
82
15
            invalidateCache()
83
15
        }
84
    }
85
86
    var internalFavoritesOnly: Bool = false
87
    var favoritesOnly: Bool {
88
231
        get { internalFavoritesOnly }
89
5
        set {
90
5
            internalFavoritesOnly = newValue
91
5
            self.defaults.set(newValue, forKey: searchFavoritesOnly)
92
5
            invalidateCache()
93
5
        }
94
    }
95
96
    private var searchModeKey = "searchMode"
97
    private var searchFavoritesOnly = "searchFavoritesOnly"
98
    private var legacyHistoryStoreKey = "pasteStore"
99
100
    @available(*, message: "HistoryService: .legacyHistoryStore is deprecated use .historyRepo")
101
83
    private var legacyHistoryStore: [String]? = []
102
103
    init(defaults: UserDefaults = UserDefaults.standard,
104
         pasteboard: PasteboardWrapperType = PasteboardWrapper(),
105
         historyRepo: HistoryRepo = HistoryRepo(defaults: UserDefaults.standard,
106
                                                prefs: CutBoxPreferencesService.shared),
107
83
         prefs: CutBoxPreferencesService = CutBoxPreferencesService.shared) {
108
83
109
83
        self.defaults = defaults
110
83
        self.pasteboard = pasteboard
111
83
        self.historyRepo = historyRepo
112
83
113
83
        // swiftlint:disable identifier_name
114
83
        let migration_1_6_x = HistoryStoreMigration_1_6_x(defaults: defaults)
115
83
        if migration_1_6_x.isMigrationRequired {
116
1
            migration_1_6_x.applyTimestampsToLegacyItems()
117
1
            print("historyStore migrated to 1.6.x - timestamps added")
118
83
        }
119
83
        // swiftlint:enable identifier_name
120
83
121
83
        self.internalFavoritesOnly = self.defaults.bool(forKey: searchFavoritesOnly)
122
83
123
83
        if let legacyHistoryStoreDefaults = defaults.array(forKey: self.legacyHistoryStoreKey) {
124
1
            self.migrateLegacyHistoryStore(legacyHistoryStoreDefaults, defaults)
125
83
        } else {
126
82
            self.historyRepo.loadFromDefaults()
127
83
        }
128
83
129
83
        self.events.onNext(.didLoadDefaults) // not currently used
130
83
    }
131
132
338
    private func truncateItems() {
133
338
        let limit = self.historyLimit
134
338
        if limit > 0 && historyRepo.items.count > limit {
135
3
            historyRepo.removeSubrange(limit..<historyRepo.items.count)
136
338
        }
137
338
        invalidateCache()
138
338
    }
139
140
404
    func invalidateCache() {
141
404
        itemsCache = nil
142
404
        dictCache = nil
143
404
    }
144
145
    private var itemsCache: [String]?
146
208
    var items: [String] {
147
208
        if let cached = itemsCache {
148
101
            return cached
149
107
        }
150
107
151
107
        let historyItems: [String] =
152
107
            self.favoritesOnly ?
153
107
                historyRepo.favorites
154
107
                : historyRepo.items
155
107
156
107
        let cache: [String]
157
107
        if let search = self.filterText, !search.isEmpty {
158
8
            switch searchMode {
159
8
            case .fuzzyMatch:
160
3
                cache = historyItems.fuzzySearchRankedFiltered(
161
3
                    search: search,
162
3
                    score: Constants.searchFuzzyMatchMinScore)
163
8
            case .regexpAnyCase:
164
2
                cache = historyItems.regexpSearchFiltered(
165
2
                    search: search,
166
2
                    options: [.caseInsensitive])
167
8
            case .regexpStrictCase:
168
2
                cache = historyItems.regexpSearchFiltered(
169
2
                    search: search,
170
2
                    options: [])
171
8
            case .substringMatch:
172
1
                cache = historyItems.substringSearchFiltered(
173
1
                    search: search
174
1
                )
175
8
            }
176
107
        } else {
177
99
            cache = historyItems
178
107
        }
179
107
180
107
        itemsCache = cache
181
107
        return cache
182
107
    }
183
184
    private var dictCache: [[String: String]]?
185
167
    var dict: [[String: String]] {
186
167
        if let cached = dictCache {
187
144
            return cached
188
144
        }
189
23
190
23
        let historyItems: [[String: String]]!
191
23
        if self.favoritesOnly {
192
2
            historyItems = historyRepo.favoritesDict
193
23
        } else {
194
21
            historyItems = historyRepo.dict
195
23
        }
196
23
197
23
        let cache: [[String: String]]
198
23
        if let search = self.filterText, search != "" {
199
5
            switch searchMode {
200
5
            case .fuzzyMatch:
201
1
                cache = historyItems.fuzzySearchRankedFiltered(
202
1
                    search: search,
203
1
                    score: Constants.searchFuzzyMatchMinScore)
204
5
            case .regexpAnyCase:
205
1
                cache = historyItems.regexpSearchFiltered(
206
1
                    search: search,
207
1
                    options: [.caseInsensitive])
208
5
            case .regexpStrictCase:
209
1
                cache = historyItems.regexpSearchFiltered(
210
1
                    search: search,
211
1
                    options: [])
212
5
            case .substringMatch:
213
2
                cache = historyItems.substringSearchFiltered(
214
2
                    search: search
215
2
                )
216
5
            }
217
23
        } else {
218
18
            cache = historyItems
219
23
        }
220
23
221
23
        dictCache = cache
222
23
        return cache
223
23
    }
224
225
155
    var count: Int {
226
155
        return items.count
227
155
    }
228
229
77
    func beginPolling() {
230
77
        guard self.pollingTimer == nil else {
231
24
            return
232
53
        }
233
53
234
53
        self.pollingTimer = Timer.scheduledTimer(
235
53
            timeInterval: 0.2,
236
53
            target: self,
237
53
            selector: #selector(self.pollPasteboard),
238
53
            userInfo: nil,
239
53
            repeats: true)
240
53
    }
241
242
31
    func endPolling() {
243
31
        guard self.pollingTimer != nil else {
244
29
            return
245
29
        }
246
2
        self.pollingTimer?.invalidate()
247
2
        self.pollingTimer = nil
248
2
    }
249
250
    @discardableResult
251
3
    func toggleSearchMode() -> HistorySearchMode {
252
3
        self.searchMode = self.searchMode.next
253
3
        return self.searchMode
254
3
    }
255
256
2
    func clear() {
257
2
        self.historyRepo.clearHistory()
258
2
        self.invalidateCache()
259
2
        self.events.onNext(.didClearHistory) // not currently used
260
2
    }
261
262
    /// Clear history using timestamp predicate
263
    /// see historyOffsetPredicateFactory(offset: TimeInterval) -> (String) -> Bool
264
2
    func clearWithTimestampPredicate(predicate: (String) -> Bool) {
265
2
        self.historyRepo.clearHistory(timestampPredicate: predicate)
266
2
        self.invalidateCache()
267
2
    }
268
269
0
    func clearHistoryByTimeOffset(offset: TimeInterval) {
270
0
        if offset == 0 {
271
0
            self.clear()
272
0
        } else {
273
0
            let predicate: (String) -> Bool = historyOffsetPredicateFactory(offset: offset)
274
0
            self.clearWithTimestampPredicate(predicate: predicate)
275
0
        }
276
0
    }
277
278
2
    private func itemSelectionToHistoryIndexes(selected: IndexSet) -> IndexSet {
279
2
        return IndexSet(selected
280
3
            .compactMap { self.items[safe: $0] }
281
3
            .map { self.historyRepo.items.firstIndex(of: $0) }
282
3
            .compactMap { $0 })
283
2
    }
284
285
2
    private func itemSelectionToHistoryDictIndexes(selected: IndexSet) -> IndexSet {
286
4
        let storeStrings = historyRepo.dict.map { $0["string"] }
287
2
        let dictIndexes = IndexSet(
288
2
            selected.map {
289
1
                storeStrings
290
1
                    .firstIndex(of: items[$0])!
291
1
            }
292
2
        )
293
2
        return dictIndexes
294
2
    }
295
296
2
    func remove(selected: IndexSet) {
297
2
        let indexes = itemSelectionToHistoryIndexes(selected: selected)
298
2
299
2
        if indexes.contains(0) {
300
2
            self.removeGuard = self.historyRepo.items[0]
301
2
        }
302
2
303
2
        self.historyRepo
304
2
            .removeAtIndexes(indexes: indexes)
305
2
        self.invalidateCache()
306
2
    }
307
308
2
    func toggleFavorite(items: IndexSet) {
309
2
        let indexes = itemSelectionToHistoryDictIndexes(selected: items)
310
2
        self.historyRepo
311
2
            .toggleFavorite(indexes: indexes)
312
2
        self.invalidateCache()
313
2
    }
314
315
335
    func saveToDefaults() {
316
335
        self.historyRepo.saveToDefaults()
317
335
        self.events.onNext(.didSaveDefaults) // not currently used
318
335
    }
319
320
30
    deinit {
321
30
        self.endPolling()
322
30
    }
323
324
410
    @objc func pollPasteboard() {
325
410
        let (clip, isFavorite) = self.replaceWithLatest()
326
410
        guard clip != nil else {
327
75
            return
328
335
        }
329
335
330
335
        if clip!.isEmpty {
331
1
            self.removeLatest()
332
335
        } else {
333
334
            self.historyRepo.insert(clip!, isFavorite: isFavorite)
334
334
            self.truncateItems()
335
335
        }
336
335
        self.saveToDefaults()
337
335
    }
338
339
410
    func replaceWithLatest() -> (String?, Bool) {
340
410
        guard let currentClip = clipboardContent() else {
341
75
            return (nil, false)
342
335
        }
343
335
344
335
        let isFavorite = historyRepo.favorites.contains(currentClip)
345
335
346
335
        if let removeGuard = self.removeGuard,
347
335
            currentClip == removeGuard {
348
0
            return (nil, false)
349
335
        } else {
350
335
            self.removeGuard = nil
351
335
        }
352
335
353
335
        let indexes = historyRepo.findIndexSetOf(string: currentClip)
354
335
        if let indexOfClip = indexes.first {
355
1
            if indexOfClip == 0 {
356
0
                return (nil, false)
357
1
            }
358
1
            historyRepo.remove(at: indexOfClip)
359
335
        }
360
335
361
335
        return (
362
335
            historyRepo.items.first == currentClip
363
335
            ? nil
364
335
            : currentClip,
365
335
            isFavorite
366
335
        )
367
335
    }
368
369
411
    func clipboardContent() -> String? {
370
411
        return pasteboard.pasteboardItems?
371
411
            .first?
372
411
            .string(forType: .string)
373
411
    }
374
375
51
    func historyMemorySize() -> String {
376
51
        return self.historyRepo.bytesFormatted()
377
51
    }
378
379
1
    func removeLatest() {
380
1
        self.historyRepo.remove(at: 0)
381
1
        self.invalidateCache()
382
1
        NSPasteboard.general.clearContents()
383
1
        if let topClip = self.historyRepo.items.first {
384
1
            NSPasteboard.general.setString(topClip, forType: .string)
385
1
        }
386
1
    }
387
388
4
    func setTimeFilter(seconds: Double?) {
389
4
        self.historyRepo.timeFilter = seconds
390
4
        self.invalidateCache()
391
4
        self.events.onNext(.didLoadDefaults) // not currently used
392
4
    }
393
394
10
    func migrateLegacyHistoryStore(_ legacyHistoryStoreDefaults: [Any], _ defaults: UserDefaults) {
395
10
        self.legacyHistoryStore = legacyHistoryStoreDefaults as? [String]
396
10
        self.historyRepo.loadFromDefaults()
397
10
398
10
        if let legacyHistoryStore = self.legacyHistoryStore {
399
10
            self.historyRepo.migrateLegacyPasteStore(legacyHistoryStore)
400
10
            self.historyRepo.saveToDefaults()
401
10
        }
402
10
403
10
        defaults.removeObject(forKey: self.legacyHistoryStoreKey)
404
10
    }
405
}