# 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

    }