├── LICENSE.md ├── Package.swift ├── README.md ├── Sources └── ScreenieCore │ ├── ImageFile+OCR.swift │ ├── ImageFile.swift │ ├── ImageWordsCache.swift │ ├── IndexItem.swift │ ├── Indexer.swift │ ├── ProcessedLanguage.swift │ ├── String+NLP.swift │ └── ThreadSafeVendor.swift ├── Tests ├── LinuxMain.swift └── ScreenieCoreTests │ ├── ScreenieCoreTests.swift │ └── XCTestManifests.swift └── images └── example.jpg /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ScreenieCore", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 14 | .library( 15 | name: "ScreenieCore", 16 | targets: ["ScreenieCore"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 25 | .target( 26 | name: "ScreenieCore", 27 | dependencies: []), 28 | .testTarget( 29 | name: "ScreenieCoreTests", 30 | dependencies: ["ScreenieCore"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Screenie Core 2 | 3 | [Screenie](https://www.thnkdev.com/Screenie/) is a macOS screenshot manager to quickly access and search your screenshots. Screenie Core combines the OCR and indexing functionaliy powering the app. Text recognition is done using the [Vision](https://developer.apple.com/documentation/vision/vnrecognizetextrequest) framework. With some additional parsing to increase recall. 4 | 5 | ![](images/example.jpg) 6 | 7 | ## Getting Started 8 | 9 | The main interface to Screenie Core is the `Indexer` class. You provide the `Indexer` with a collection of `IndexItem`s. The items implement a single function: 10 | 11 | ``` 12 | public protocol IndexItem: Hashable { 13 | func getSearchableRepresentation( 14 | indexContext: IndexContext, 15 | tokenizer: NLTokenizer, 16 | progressHandler: @escaping (Double) -> Void, 17 | completion: @escaping (SearchableRepresentation) -> Void) 18 | } 19 | ``` 20 | 21 | See [ScreenieCoreTests.swift](Tests/ScreenieCoreTests/ScreenieCoreTests.swift) for a simple example. 22 | 23 | The index on it's own doesn't perform OCR, you can use `ImageFile`s `findText` function to determine which words you're intrested in. 24 | 25 | ``` 26 | let imageFile = MyConcreteImageFile() 27 | imageFile.findText(progressHandler: { _ in }, completion: { wordProvider in 28 | print(wordProvider(2, 0.5)) 29 | }) 30 | ``` 31 | 32 | ### Prerequisites 33 | 34 | Requires macOS 10.15+ or iOS 13+ 35 | 36 | ### Installing 37 | 38 | ScreenieCore uses [SPM](https://swift.org/package-manager/). To open, simply drag and drop Package.swift to xcode in the dock. 39 | 40 | ## License 41 | 42 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 43 | 44 | -------------------------------------------------------------------------------- /Sources/ScreenieCore/ImageFile+OCR.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Noah Martin on 1/20/20. 6 | // 7 | 8 | import Foundation 9 | import Vision 10 | 11 | public typealias WordProvider = (_ maxCandidates: Int, _ minConfidence: VNConfidence) -> [[Text]] 12 | 13 | extension ImageFile { 14 | 15 | // This call will block until the completion handler is called 16 | public func findText( 17 | progressHandler: @escaping (Double) -> Void, 18 | completion: @escaping (WordProvider) -> Void) 19 | { 20 | var hasCalledCompletion: Bool = false 21 | let request = VNRecognizeTextRequest { request, error in 22 | guard let requestResults = request.results, error == nil else { 23 | hasCalledCompletion = true 24 | completion({_ , _ in []}) 25 | return 26 | } 27 | 28 | guard let results = requestResults as? [VNRecognizedTextObservation] else { 29 | fatalError("Wrong result type") 30 | } 31 | 32 | let getWords = { maxCandidates, minConfidence in 33 | results.compactMap { observation in 34 | observation 35 | .topCandidates(maxCandidates) 36 | .filter({ $0.confidence >= minConfidence }) 37 | .reduce([Text]()) { acc, text in 38 | acc + [Text(string: text.string, confidence: text.confidence)] 39 | } 40 | } 41 | } 42 | 43 | hasCalledCompletion = true 44 | completion(getWords) 45 | } 46 | request.preferBackgroundProcessing = true 47 | request.recognitionLanguages = ["en-US"] 48 | request.usesLanguageCorrection = true 49 | request.recognitionLevel = .accurate 50 | request.customWords = ["Screenie", "QuickRes", "ThnkDev"] 51 | request.progressHandler = { _, progress, _ in 52 | progressHandler(progress) 53 | } 54 | let requestHandler = VNImageRequestHandler(url: self.url) 55 | do { 56 | try requestHandler.perform([request]) 57 | } catch { 58 | if !hasCalledCompletion { 59 | completion({_ , _ in []}) 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/ScreenieCore/ImageFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageFile.swift 3 | // ScreenieCore 4 | // 5 | // Created by Noah Martin on 1/20/20. 6 | // Copyright © 2020 Noah Martin. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol ImageFile { 12 | var url: URL { get } 13 | var cacheKey: String { get } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/ScreenieCore/ImageWordsCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageWordsCache.swift 3 | // ScreenieCore 4 | // 5 | // Created by Noah Martin on 1/20/20. 6 | // Copyright © 2020 Noah Martin. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public final class ImageWordsCache { 12 | public static let shared = ImageWordsCache() 13 | 14 | init?() { 15 | if let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { 16 | self.cacheFile = directory.appendingPathComponent("imageTagCache_v2") 17 | } else { 18 | return nil 19 | } 20 | cacheSize = 0 21 | 22 | cacheQueue.async(flags: .barrier) { 23 | if let data = try? Data(contentsOf: self.cacheFile) { 24 | self.cacheSize = data.count 25 | self.cache = try? JSONDecoder().decode(Cache.self, from: data) 26 | } else { 27 | self.cacheSize = 0 28 | } 29 | } 30 | } 31 | 32 | public private(set) var cacheSize: Int 33 | 34 | public func readFromCache(screenshot: ImageFile) -> [[Text]]? { 35 | let cacheID = screenshot.cacheKey 36 | return cacheQueue.sync { 37 | return self.cache?.items[cacheID] 38 | } 39 | } 40 | 41 | public func writeToCache(screenshot: ImageFile, words: [[Text]]) { 42 | let cacheID = screenshot.cacheKey 43 | if cache == nil { 44 | cache = Cache(items: [:]) 45 | } 46 | cacheQueue.async(flags: .barrier) { 47 | self.cache?.items[cacheID] = words 48 | do { 49 | let data = try self.encoder.encode(self.cache) 50 | self.cacheSize = data.count 51 | try data.write(to: self.cacheFile) 52 | } catch { 53 | print("Error saving data \(error)") 54 | } 55 | } 56 | } 57 | 58 | public func clear(completion: @escaping () -> Void) { 59 | cacheQueue.async(flags: .barrier) { 60 | self.cache = nil 61 | self.cacheSize = 0 62 | try? FileManager.default.removeItem(at: self.cacheFile) 63 | DispatchQueue.main.async { 64 | completion() 65 | } 66 | } 67 | } 68 | 69 | private let cacheFile: URL 70 | private var cache: Cache? 71 | private let encoder = JSONEncoder() 72 | private let cacheQueue = DispatchQueue(label: "com.thnkdev.screenie.cache_queue", qos: .userInitiated, attributes: .concurrent) 73 | } 74 | 75 | struct Cache: Codable { 76 | var items: [String: [[Text]]] 77 | } 78 | -------------------------------------------------------------------------------- /Sources/ScreenieCore/IndexItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScreenieCore.swift 3 | // ScreenieCore 4 | // 5 | // Created by Noah Martin on 1/20/20. 6 | // Copyright © 2020 Noah Martin. All rights reserved. 7 | // 8 | 9 | import NaturalLanguage 10 | 11 | public class IndexContext { 12 | public init() { } 13 | 14 | public let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.date.rawValue) 15 | } 16 | 17 | public struct Text: Codable, Hashable { 18 | public let string: String 19 | public let confidence: Float 20 | } 21 | 22 | public struct SearchableRepresentation { 23 | public init(text: [[Text]], dates: Set) { 24 | self.text = text 25 | self.dates = dates 26 | } 27 | 28 | public let text: [[Text]] 29 | public let dates: Set 30 | } 31 | 32 | public protocol IndexItem: Hashable { 33 | func getSearchableRepresentation( 34 | indexContext: IndexContext, 35 | progressHandler: @escaping (Double) -> Void, 36 | completion: @escaping (SearchableRepresentation) -> Void) 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Sources/ScreenieCore/Indexer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Indexer.swift 3 | // QuickShot 4 | // 5 | // Created by Noah Martin on 6/30/19. 6 | // Copyright © 2019 Noah Martin. All rights reserved. 7 | // 8 | 9 | import NaturalLanguage 10 | import Foundation 11 | import Combine 12 | 13 | public enum IndexSpeed: String { 14 | case standard 15 | case fast 16 | } 17 | 18 | public final class Indexer { 19 | 20 | public init( 21 | speed: IndexSpeed, 22 | progressQueue: DispatchQueue = DispatchQueue(label: "com.thnkdev.screenie.progressQueue"), 23 | accesQueue: DispatchQueue = DispatchQueue(label: "com.ThnkDev.Screenie.index_access", attributes: DispatchQueue.Attributes.concurrent)) 24 | { 25 | self.progressQueue = progressQueue 26 | self.index = Index(accessQueue: accesQueue) 27 | operationQueue.qualityOfService = .background 28 | let processors = ProcessInfo.processInfo.processorCount 29 | let maxOperations = speed == .standard ? max(1, processors-2) : processors 30 | operationQueue.maxConcurrentOperationCount = maxOperations 31 | vendor = ThreadSafeVendor(maxItems: maxOperations) { 32 | NLTagger(tagSchemes: [.lemma]) 33 | } 34 | } 35 | 36 | @Published public private(set) var isFinished: Bool = true 37 | @Published public private(set) var totalProgress: Double = 0 38 | 39 | let index: Index 40 | 41 | public func indexItems(diff: CollectionDifference, completion: @escaping (Double) -> Void) { 42 | let shouldReportProgress: Bool 43 | if startingItemsCount == nil { 44 | startingItemsCount = diff.insertions.count 45 | shouldReportProgress = true 46 | if diff.insertions.count > 0 { 47 | totalProgress = 0 48 | isFinished = false 49 | } 50 | } else { 51 | shouldReportProgress = false 52 | } 53 | 54 | let beginTime = DispatchTime.now() 55 | let completedOperation = BlockOperation { [weak self] in 56 | let endTime = DispatchTime.now() 57 | if shouldReportProgress { 58 | self?.progressQueue.sync { 59 | self?.isFinished = true 60 | } 61 | } 62 | completion(Double(endTime.uptimeNanoseconds - beginTime.uptimeNanoseconds)/1_000_000_000) 63 | } 64 | completedOperation.qualityOfService = .background 65 | for collectionDiff in diff.insertions + diff.removals { 66 | switch collectionDiff { 67 | case .insert(offset: _, element: let item, associatedWith: _): 68 | let op = insertOperation(for: item, shouldReportProgress: shouldReportProgress) 69 | completedOperation.addDependency(op) 70 | operationQueue.addOperation(op) 71 | case .remove(offset: _, element: let item, associatedWith: _): 72 | operationQueue.addBarrierBlock { [weak self] in 73 | self?.index.remove(item: item) 74 | } 75 | break 76 | } 77 | } 78 | operationQueue.addOperation(completedOperation) 79 | } 80 | 81 | public func resume() { 82 | operationQueue.isSuspended = false 83 | } 84 | 85 | public func pause() { 86 | operationQueue.isSuspended = true 87 | } 88 | 89 | public func query(string: String) -> [A] { 90 | index.query(string: string) 91 | } 92 | 93 | public func debug(item: A) -> ProcessedLanguage? { 94 | index.debug(item: item) 95 | } 96 | 97 | private let indexContext = IndexContext() 98 | private var startingItemsCount: Int? = nil 99 | private let operationQueue = OperationQueue() 100 | private let vendor: ThreadSafeVendor 101 | private let progressQueue: DispatchQueue 102 | 103 | // Always accessed on progressQueue 104 | private var completed: Double = 0 { 105 | didSet { 106 | guard let startingItemsCount = startingItemsCount, startingItemsCount > 0 else { return } 107 | 108 | let result = completed/Double(startingItemsCount) 109 | totalProgress = result 110 | } 111 | } 112 | 113 | private func insertOperation(for item: A, shouldReportProgress: Bool) -> Operation { 114 | let op = BlockOperation { [indexContext = self.indexContext] in 115 | var lastProgress: Double = 0 116 | self.vendor.vend { [weak self] object in 117 | item.getSearchableRepresentation( 118 | indexContext: indexContext, 119 | progressHandler: { [weak self] theProgress in 120 | guard let self = self, shouldReportProgress else { return } 121 | 122 | self.progressQueue.sync { 123 | self.completed = self.completed + (theProgress - lastProgress) 124 | } 125 | lastProgress = theProgress 126 | }) { [weak self] searchable in 127 | guard let self = self else { return } 128 | self.index.add( 129 | dates: searchable.dates, 130 | item: item) 131 | self.index.add( 132 | keys: searchable.text, 133 | item: item, 134 | tagger: object) 135 | self.progressQueue.sync { 136 | self.completed = self.completed + (1.0 - lastProgress) 137 | } 138 | } 139 | } 140 | } 141 | op.qualityOfService = .background 142 | return op 143 | } 144 | 145 | } 146 | 147 | final class Index { 148 | 149 | init(accessQueue: DispatchQueue) { 150 | self.accessQueue = accessQueue 151 | } 152 | 153 | private struct QueryResult: Hashable { 154 | let value: A 155 | let weight: Double 156 | } 157 | 158 | func add(keys: [[Text]], item: A, tagger: NLTagger) { 159 | var lemmaFrequency = [String: Int]() 160 | var originalWordFrequency = [String: Int]() 161 | var wordKeys = Set() 162 | for key in keys { 163 | for text in key { 164 | tagger.string = text.string 165 | tagger.enumerateTags(in: tagger.string!.startIndex.. Bool in 166 | guard let originalString = tagger.string?[range].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { return true } 167 | 168 | let computedValue = (tag?.rawValue ?? String(originalString)).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 169 | if !computedValue.isEmpty { 170 | if let freq = lemmaFrequency[computedValue] { 171 | lemmaFrequency[computedValue] = freq + 1 172 | } else { 173 | lemmaFrequency[computedValue] = 1 174 | } 175 | wordKeys.insert(computedValue) 176 | if originalString != computedValue { 177 | if let freq = originalWordFrequency[originalString] { 178 | originalWordFrequency[originalString] = freq + 1 179 | } else { 180 | originalWordFrequency[originalString] = 1 181 | } 182 | wordKeys.insert(originalString) 183 | } 184 | } 185 | return true 186 | } 187 | } 188 | } 189 | accessQueue.async(flags: .barrier) { 190 | self.discoveredWords[item] = ProcessedLanguage(recognizedText: keys, lemmas: lemmaFrequency, originalWords: originalWordFrequency) 191 | for key in wordKeys { 192 | if self.mapping[key] != nil { 193 | self.mapping[key]?.append(item) 194 | } else { 195 | self.mapping[key] = [item] 196 | } 197 | } 198 | } 199 | } 200 | 201 | func add(dates: Set, item: A) { 202 | accessQueue.async(flags: .barrier) { 203 | for date in dates { 204 | if self.dateMapping[date] != nil { 205 | self.dateMapping[date]?.append(item) 206 | } else { 207 | self.dateMapping[date] = [item] 208 | } 209 | } 210 | } 211 | } 212 | 213 | func remove(item: A) { 214 | accessQueue.async(flags: .barrier) { 215 | self.discoveredWords[item] = nil 216 | for key in self.dateMapping.keys { 217 | self.dateMapping[key] = self.dateMapping[key]?.filter { $0 != item } 218 | } 219 | for key in self.mapping.keys { 220 | self.mapping[key] = self.mapping[key]?.filter { $0 != item } 221 | } 222 | } 223 | } 224 | 225 | func debug(item: A) -> ProcessedLanguage? { 226 | var result: ProcessedLanguage? = nil 227 | accessQueue.sync { 228 | result = discoveredWords[item] 229 | } 230 | return result 231 | } 232 | 233 | private let accessQueue: DispatchQueue 234 | private var discoveredWords = [A: ProcessedLanguage]() 235 | private var mapping = [String: [A]]() 236 | private var dateMapping = [Date: [A]]() 237 | private let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.date.rawValue) 238 | 239 | // Safe to call from any thread 240 | fileprivate func query(string: String) -> [A] { 241 | // Get dates for this candidate 242 | let matches = detector?.matches(in: string, options: [], range: NSMakeRange(0, string.utf16.count)) 243 | let dates = matches?.compactMap { $0.date } ?? [] 244 | 245 | var resultsToWeight = [A: Double]() 246 | var queryMatches = [(Set, Set)]() 247 | let tagger = NLTagger(tagSchemes: [.lemma]) 248 | let queries = string.lemmas(tagger: tagger) 249 | accessQueue.sync { 250 | for date in dates { 251 | for result in dateMapping[date] ?? [] { 252 | let currentWeight = resultsToWeight[result] ?? 0 253 | resultsToWeight[result] = currentWeight + 1.1 254 | } 255 | } 256 | 257 | queryMatches = queries.map { $0.lowercased() }.map { query -> (Set, Set) in 258 | let exactMatch = Set(mapping[query] ?? []) 259 | var partialMatch = Set() 260 | if query.count > 3 { 261 | for (key, value) in mapping { 262 | if key.contains(query) { 263 | partialMatch = partialMatch.union(value) 264 | } 265 | } 266 | } 267 | return (exactMatch, partialMatch) 268 | } 269 | } 270 | 271 | let queryResults: [Set>] = queryMatches.map { queryMatch in 272 | let exactMatch = queryMatch.0 273 | let partialMatch = queryMatch.1 274 | let both = exactMatch.intersection(partialMatch) 275 | let onlyExact = exactMatch.subtracting(both).map { QueryResult(value: $0, weight: 1) } 276 | let onlyPartial = partialMatch.subtracting(both).map { QueryResult(value: $0, weight: 0.1) } 277 | return Set(both.map { QueryResult(value: $0, weight: 1.1) }).union(onlyExact).union(onlyPartial) 278 | } 279 | 280 | for imageQueryResult in queryResults { 281 | for queryResult in imageQueryResult { 282 | let currentWeight = resultsToWeight[queryResult.value] ?? 0 283 | resultsToWeight[queryResult.value] = currentWeight + queryResult.weight 284 | } 285 | } 286 | return resultsToWeight.keys.sorted { first, second -> Bool in 287 | let firstWeight = resultsToWeight[first] ?? 0 288 | let secondWeight = resultsToWeight[second] ?? 0 289 | if firstWeight == secondWeight { 290 | return first > second 291 | } 292 | return firstWeight > secondWeight 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /Sources/ScreenieCore/ProcessedLanguage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProcessedLanguage.swift 3 | // 4 | // 5 | // Created by Noah Martin on 3/7/20. 6 | // 7 | 8 | import Foundation 9 | 10 | // The language representation of an item in the index. 11 | // Stored for debugging purposes 12 | public struct ProcessedLanguage { 13 | // Output from OCR 14 | public let recognizedText: [[Text]] 15 | // Individual normalized words and their frequencies 16 | public let lemmas: [String: Int] 17 | // Original words when different from the lemmas 18 | public let originalWords: [String: Int] 19 | } 20 | -------------------------------------------------------------------------------- /Sources/ScreenieCore/String+NLP.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+NLP.swift 3 | // QuickShot 4 | // 5 | // Created by Noah Martin on 7/6/19. 6 | // Copyright © 2019 Noah Martin. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import NaturalLanguage 11 | 12 | extension String { 13 | 14 | func lemmas(tagger: NLTagger) -> [String] { 15 | tagger.string = self.lowercased() 16 | var result = [String: Bool]() 17 | tagger.enumerateTags(in: tagger.string!.startIndex.. Bool in 18 | if let substring = tagger.string?[range] { 19 | let originalWord = String(substring) 20 | result[originalWord] = true 21 | if let lemma = tag?.rawValue { 22 | result[lemma] = true 23 | } 24 | } 25 | return true 26 | } 27 | return Array(result.keys) 28 | } 29 | 30 | func lemma(tagger: NLTagger) -> String { 31 | tagger.string = self 32 | var result: String? 33 | tagger.enumerateTags(in: tagger.string!.startIndex.. { 12 | public init(maxItems: Int, vendor: @escaping () -> ObjectType) { 13 | self.vendor = vendor 14 | for _ in 0.. Void) { 20 | var object: ItemWrapper? = nil 21 | accessQueue.sync { 22 | for item in list { 23 | if item.available { 24 | object = item 25 | item.available = false 26 | break 27 | } 28 | } 29 | } 30 | if let object = object { 31 | work(object.item) 32 | object.available = true 33 | } else { 34 | assertionFailure("Unexpected nil item") 35 | } 36 | } 37 | 38 | private var list = [ItemWrapper]() 39 | private let vendor: () -> ObjectType 40 | 41 | private let accessQueue = DispatchQueue(label: "accessQueue") 42 | 43 | final class ItemWrapper { 44 | init(item: ObjectType) { 45 | self.item = item 46 | available = true 47 | } 48 | 49 | let item: ObjectType 50 | var available: Bool 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import ScreenieCoreTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += ScreenieCoreTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/ScreenieCoreTests/ScreenieCoreTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import NaturalLanguage 3 | import XCTest 4 | @testable import ScreenieCore 5 | 6 | final class ScreenieCoreTests: XCTestCase { 7 | func testAddItem() { 8 | let item = TestIndexItem(string: "test") 9 | let queue = DispatchQueue(label: "testAccessQueue") 10 | let indexer = Indexer(speed: .standard, progressQueue: DispatchQueue.main, accesQueue: queue) 11 | let exp = expectation(description: "finished indexing") 12 | indexer.indexItems(diff: CollectionDifference([CollectionDifference.Change.insert(offset: 0, element: item, associatedWith: nil)])!, completion: { _ in }) 13 | cancellable = indexer.$isFinished.sink { [weak self] finished in 14 | guard finished && self?.cancellable != nil else { return } 15 | 16 | self?.cancellable = nil 17 | queue.async { 18 | exp.fulfill() 19 | } 20 | } 21 | wait(for: [exp], timeout: 10) 22 | let result = indexer.query(string: "Test") 23 | XCTAssertEqual(result.first, item) 24 | } 25 | 26 | var cancellable: Cancellable? 27 | 28 | static var allTests = [ 29 | ("testExample", testAddItem), 30 | ] 31 | } 32 | 33 | struct TestIndexItem: IndexItem, Comparable { 34 | 35 | let string: String 36 | 37 | static func < (lhs: TestIndexItem, rhs: TestIndexItem) -> Bool { 38 | lhs.string < rhs.string 39 | } 40 | 41 | func getSearchableRepresentation( 42 | indexContext: IndexContext, 43 | progressHandler: @escaping (Double) -> Void, 44 | completion: @escaping (SearchableRepresentation) -> Void) 45 | { 46 | progressHandler(1.0) 47 | completion(SearchableRepresentation(text: [[Text(string: string, confidence: 1.0)]], dates: Set())) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/ScreenieCoreTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(ScreenieCoreTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /images/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noahsmartin/screenie-core/7b816722a9d8b3c0c85a0938c9a346b29df740a9/images/example.jpg --------------------------------------------------------------------------------