.../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 | | } |