diff --git a/PaperLib.xcodeproj/project.pbxproj b/PaperLib.xcodeproj/project.pbxproj index 1e858135..c0a58a8b 100644 --- a/PaperLib.xcodeproj/project.pbxproj +++ b/PaperLib.xcodeproj/project.pbxproj @@ -64,6 +64,8 @@ BEB7583227559A700086E360 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEB7583127559A700086E360 /* SearchBar.swift */; }; BEB7583427559CF00086E360 /* Misc.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEB7583327559CF00086E360 /* Misc.swift */; }; BEE4DA2727DB64DA00482A08 /* SupComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE4DA2627DB64DA00482A08 /* SupComponent.swift */; }; + BEE4DA2927DBB09000482A08 /* CacheRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE4DA2827DBB09000482A08 /* CacheRepository.swift */; }; + BEE4DA2B27DBB64F00482A08 /* PaperEntityCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE4DA2A27DBB64F00482A08 /* PaperEntityCache.swift */; }; BEEB7AA22762C93B00383CCA /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEB7AA12762C93B00383CCA /* Updater.swift */; }; BEF5586327BDA9EC00BB281B /* Exporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEF5586227BDA9EC00BB281B /* Exporter.swift */; }; BEFAB71427C1543900804026 /* Preference.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEFAB71327C1543900804026 /* Preference.swift */; }; @@ -123,6 +125,8 @@ BEB7583127559A700086E360 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; BEB7583327559CF00086E360 /* Misc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Misc.swift; sourceTree = ""; }; BEE4DA2627DB64DA00482A08 /* SupComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupComponent.swift; sourceTree = ""; }; + BEE4DA2827DBB09000482A08 /* CacheRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheRepository.swift; sourceTree = ""; }; + BEE4DA2A27DBB64F00482A08 /* PaperEntityCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaperEntityCache.swift; sourceTree = ""; }; BEEB7AA12762C93B00383CCA /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; }; BEEB7AA32762CB1300383CCA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; BEF5586227BDA9EC00BB281B /* Exporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exporter.swift; sourceTree = ""; }; @@ -352,6 +356,7 @@ BE79C9BC275131A9008F3E97 /* DBRepository.swift */, BE5BE4A427582B2300E6F832 /* WebRepository.swift */, BE5BE4A627582C6A00E6F832 /* FileRepository.swift */, + BEE4DA2827DBB09000482A08 /* CacheRepository.swift */, ); path = Repositories; sourceTree = ""; @@ -361,6 +366,7 @@ children = ( BE79C9C7275142D1008F3E97 /* PaperEntity.swift */, BE79C9CE275154D5008F3E97 /* PaperCategorizer.swift */, + BEE4DA2A27DBB64F00482A08 /* PaperEntityCache.swift */, ); path = Models; sourceTree = ""; @@ -510,6 +516,7 @@ BE5376C727C513CA005984AD /* GeneralPage.swift in Sources */, BE79C9B427512F96008F3E97 /* DependencyInjector.swift in Sources */, BE55162727C45E0D00DB92D8 /* Textfield.swift in Sources */, + BEE4DA2B27DBB64F00482A08 /* PaperEntityCache.swift in Sources */, BE79C9B227512EEA008F3E97 /* SidebarView.swift in Sources */, BE79C9BA275130F8008F3E97 /* EntitiesInteractor.swift in Sources */, BE79C9BD275131A9008F3E97 /* DBRepository.swift in Sources */, @@ -520,6 +527,7 @@ BE5376C127C47457005984AD /* Table.swift in Sources */, BE79C9C8275142D1008F3E97 /* PaperEntity.swift in Sources */, BE07383727BC61B90072F8CC /* DBLP.swift in Sources */, + BEE4DA2927DBB09000482A08 /* CacheRepository.swift in Sources */, BE79C9D72752F9F4008F3E97 /* DetailSection.swift in Sources */, BE79C99E27512D02008F3E97 /* ContentView.swift in Sources */, BE79C9D32752DCFF008F3E97 /* DetailView.swift in Sources */, diff --git a/PaperLib.xcodeproj/project.xcworkspace/xcuserdata/geo.xcuserdatad/UserInterfaceState.xcuserstate b/PaperLib.xcodeproj/project.xcworkspace/xcuserdata/geo.xcuserdatad/UserInterfaceState.xcuserstate index d0f95976..fad7d978 100644 Binary files a/PaperLib.xcodeproj/project.xcworkspace/xcuserdata/geo.xcuserdatad/UserInterfaceState.xcuserstate and b/PaperLib.xcodeproj/project.xcworkspace/xcuserdata/geo.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/PaperLib/AppEnvironment.swift b/PaperLib/AppEnvironment.swift index 6ef3a8db..3d85e08d 100644 --- a/PaperLib/AppEnvironment.swift +++ b/PaperLib/AppEnvironment.swift @@ -26,8 +26,9 @@ extension AppEnvironment { let dbRepository = RealDBRepository(sharedState: sharedState) let fileRepository = RealFileDBRepository() let webRepository = RealWebRepository() + let cacheRepository = RealCacheRepository(sharedState: sharedState) - return .init(dbRepository: dbRepository, fileRepository: fileRepository, webRepository: webRepository) + return .init(dbRepository: dbRepository, fileRepository: fileRepository, webRepository: webRepository, cacheRepository: cacheRepository) } private static func configuredInteractors(sharedState: SharedState, repositories: DIContainer.Repositories) -> DIContainer.Interactors { @@ -35,7 +36,8 @@ extension AppEnvironment { sharedState: sharedState, dbRepository: repositories.dbRepository, fileRepository: repositories.fileRepository, - webRepository: repositories.webRepository + webRepository: repositories.webRepository, + cacheRepository: repositories.cacheRepository ) return .init(entitiesInteractor: entitiesInteractor) @@ -47,5 +49,6 @@ extension DIContainer { let dbRepository: DBRepository let fileRepository: FileRepository let webRepository: WebRepository + let cacheRepository: CacheRepository } } diff --git a/PaperLib/Injected/AppState.swift b/PaperLib/Injected/AppState.swift index 54af257c..545a5b2f 100644 --- a/PaperLib/Injected/AppState.swift +++ b/PaperLib/Injected/AppState.swift @@ -77,6 +77,8 @@ struct ViewState { var processingQueueCount = StateWrapper(0) var realmReinited = StateWrapper(nil as Date?) + + var searchMode = StateWrapper(SearchMode.general) } struct SharedData { diff --git a/PaperLib/Interactors/EntitiesInteractor.swift b/PaperLib/Interactors/EntitiesInteractor.swift index 2aaa45b8..458251de 100644 --- a/PaperLib/Interactors/EntitiesInteractor.swift +++ b/PaperLib/Interactors/EntitiesInteractor.swift @@ -16,6 +16,7 @@ enum InteractorError: Error { case FileError(error: Error) case WebError(error: Error) case DBError(error: Error) + case CacheError(error: Error) } protocol EntitiesInteractor { @@ -50,20 +51,22 @@ class RealEntitiesInteractor: EntitiesInteractor { let dbRepository: DBRepository let fileRepository: FileRepository let webRepository: WebRepository + let cacheRepository: CacheRepository let exporter: Exporter - let cancelBags: CancelBags = .init(["apiVersion", "timer", "entities", "categorizer-tags", "categorizer-folders", "entitiesByIds", "update", "add", "scrape", "delete", "edit", "folders-edit", "delete-categorizer", "open-lib", "plugin", "tag", "folder"]) + let cancelBags: CancelBags = .init(["apiVersion", "timer", "entities", "categorizer-tags", "categorizer-folders", "entitiesByIds", "update", "add", "scrape", "delete", "edit", "folders-edit", "delete-categorizer", "open-lib", "plugin", "tag", "folder", "cache"]) var routineTimer: Publishers.Autoconnect = Timer.publish(every: 86400, on: .main, in: .common).autoconnect() - init(sharedState: SharedState, dbRepository: DBRepository, fileRepository: FileRepository, webRepository: WebRepository) { + init(sharedState: SharedState, dbRepository: DBRepository, fileRepository: FileRepository, webRepository: WebRepository, cacheRepository: CacheRepository) { self.logger = Logger() self.sharedState = sharedState self.dbRepository = dbRepository self.fileRepository = fileRepository self.webRepository = webRepository + self.cacheRepository = cacheRepository self.exporter = Exporter() self.setRoutineTimer() @@ -88,6 +91,12 @@ class RealEntitiesInteractor: EntitiesInteractor { return InteractorError.DBError(error: dbError) } } + .flatMap { entities in + self.cacheRepository.filter(entities: entities, query: search) + .mapError { cacheError in + return InteractorError.CacheError(error: cacheError) + } + } .sinkToLoadable { entities.wrappedValue.setIsLoading(cancelBag: self.cancelBags[cancelBagKey]) entities.wrappedValue = $0 @@ -111,6 +120,7 @@ class RealEntitiesInteractor: EntitiesInteractor { func add(from urlList: [URL]) { self.cancelBags.cancel(for: "add") + self.cancelBags.cancel(for: "cache") // 1. Files processing and web scraping publishers. var publisherList: [AnyPublisher] = .init() @@ -138,6 +148,9 @@ class RealEntitiesInteractor: EntitiesInteractor { let c = publisherList.count // 2. Database processing publishers. + var successedIds: [ObjectId] = .init() + let successedIdsPublisher: CurrentValueSubject<[ObjectId], InteractorError> = .init([]) + Publishers.MergeMany(publisherList) .collect() .flatMap { entityDrafts in @@ -162,6 +175,10 @@ class RealEntitiesInteractor: EntitiesInteractor { for (entityDraft, success) in zip(entityDrafts, successes) where !success { unsuccessedEntityList.append(entityDraft) } + for (entityDraft, success) in zip(entityDrafts, successes) where success { + successedIds.append(entityDraft.id) + } + return Just<[PaperEntityDraft]> .withErrorType(unsuccessedEntityList, InteractorError.self) .eraseToAnyPublisher() @@ -181,13 +198,47 @@ class RealEntitiesInteractor: EntitiesInteractor { } self.logger.error("Cannot add this paper: \(String(describing: error))") } - case .finished: self.logger.info("Add successful.") + case .finished: do { + self.logger.info("Add successful.") + successedIdsPublisher.send(successedIds) + } } self.sharedState.viewState.processingQueueCount.value -= c }, receiveValue: { _ in }) .store(in: self.cancelBags["add"]) + + // 3. Create fulltext cache + +// successedIdsPublisher +// .flatMap { ids in +// self.dbRepository.entities(ids: Set(ids), search: nil, publication: nil, flag: false, tags: [], folders: [], sort: "desc") +// .mapError { error in +// return InteractorError.DBError(error: error) +// } +// } +// .flatMap { entities in +// self.cacheRepository.add(entities: entities) +// .mapError { error in +// return InteractorError.CacheError(error: error) +// } +// } +// .sink( +// receiveCompletion: { completion in +// switch completion { +// case .failure(let error): do { +// self.logger.error("Cannot create fulltext cache: \(String(describing: error))") +// } +// case .finished: do { +// self.logger.info("Create fulltext cache successful.") +// } +// } +// }, +// receiveValue: { _ in +// }) +// .store(in: self.cancelBags["cache"]) + } // MARK: - Delete @@ -198,6 +249,12 @@ class RealEntitiesInteractor: EntitiesInteractor { Just .withErrorType(InteractorError.self) .flatMap { + self.cacheRepository.delete(ids: Array(ids)) + .mapError { dbError in + return InteractorError.CacheError(error: dbError) + } + } + .flatMap { _ in self.dbRepository.delete(ids: Array(ids)) .mapError { dbError in return InteractorError.DBError(error: dbError) diff --git a/PaperLib/Models/PaperEntityCache.swift b/PaperLib/Models/PaperEntityCache.swift new file mode 100644 index 00000000..60d352f4 --- /dev/null +++ b/PaperLib/Models/PaperEntityCache.swift @@ -0,0 +1,20 @@ +// +// PaperEntityCache.swift +// PaperLib +// +// Created by GeoffreyChen on 11/03/2022. +// + +import Foundation +import RealmSwift + +class PaperEntityCache: Object, ObjectKeyIdentifiable { + @Persisted var fulltext: String = "" + @Persisted var id: ObjectId + + convenience init(id: ObjectId, fulltext: String?) { + self.init() + self.id = id + self.fulltext = fulltext ?? "" + } +} diff --git a/PaperLib/Repositories/CacheRepository.swift b/PaperLib/Repositories/CacheRepository.swift new file mode 100644 index 00000000..7c084d2a --- /dev/null +++ b/PaperLib/Repositories/CacheRepository.swift @@ -0,0 +1,244 @@ +// +// CacheRepository.swift +// PaperLib +// +// Created by GeoffreyChen on 11/03/2022. +// + +import Combine +import CoreData +import PDFKit +import RealmSwift +import os.log + +protocol CacheRepository { + func getConfig() async -> Realm.Configuration + func initCache(reinit: Bool) + + func filter(entities: Results, query: String?) -> AnyPublisher, CacheError> + func add(entities: Results) -> AnyPublisher + func delete(ids: [ObjectId]) -> AnyPublisher +} + +enum CacheError: Error { + case realmNilError + case filterError(error: Error) + case addError(error: Error) + case deleteError(error: Error) +} + +class RealCacheRepository: CacheRepository { + let logger: Logger + let queue: DispatchQueue = .init(label: "cacheQueue") + var sharedState: SharedState + + var cacheSchemaVersion: UInt64 = 1 + var config: Realm.Configuration? + var inited: Bool = false + + let cachePublisher: CurrentValueSubject = .init(nil) + + init(sharedState: SharedState) { + self.sharedState = sharedState + self.logger = Logger() + + self.initCache() + } + + func getConfig() -> Realm.Configuration { + self.logger.info("[Cache] Opening local cache...") + let pathStr = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("dev.geo.PaperLib").absoluteString + + var config = Realm.Configuration( + schemaVersion: self.cacheSchemaVersion, + objectTypes: [PaperEntityCache.self] + ) + + let pathURL = URL(string: pathStr ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("paperlib").absoluteString) + if let pathURL = pathURL { + do { + try FileManager.default.createDirectory(at: pathURL, withIntermediateDirectories: true, attributes: nil) + config.fileURL = pathURL.appendingPathComponent("cache.realm") + } catch let error { + self.sharedState.viewState.alertInformation.value = "[Cache] Cannot create folder: \(String(describing: error)) \(Date())" + self.logger.error("[Cache] Cannot create folder: \(String(describing: error))") + } + } else { + self.logger.error("[Cache] Cannot set custom path \(String(describing: pathStr)) for local cache.") + } + + self.config = config + return config + } + + func initCache(reinit: Bool = false) { + if !self.inited || reinit { + _ = self.getConfig() + self.inited = true + } + + var cache: Realm? + + do { + cache = try Realm(configuration: self.config!) + } catch let error { + DispatchQueue.main.async { + self.sharedState.viewState.alertInformation.value = "[Cache] Cannot open local cache: \(String(describing: error)) \(Date())" + } + self.logger.error("[Cache] Cannot open local cache: \(String(describing: error))") + } + cachePublisher.send(cache) + } + + func filter(entities: Results, query: String?) -> AnyPublisher, CacheError> { + if self.sharedState.viewState.searchMode.value == .fulltext { + return self.add(entities: entities) + .flatMap { _ -> AnyPublisher in + return self.cachePublisher.eraseToAnyPublisher() + } + .flatMap { cache -> AnyPublisher, CacheError> in + if let cache = cache { + let cachedEntities = cache.objects(PaperEntityCache.self) + + var filteredIds: Set + if let query = query, !query.isEmpty { + let filteredEntities = cachedEntities.filter("(fulltext contains[cd] '\(query)')") + filteredIds = Set(filteredEntities.map({ entity in entity.id })) + } else { + filteredIds = Set(entities.map({ entity in entity.id })) + } + + return entities.filter("id IN %@", filteredIds).collectionPublisher + .mapError { error in + return CacheError.filterError(error: error) + } + .eraseToAnyPublisher() + + } else { + self.logger.notice("[Cache] Cache nil (filter): \(String(describing: CacheError.realmNilError))") + return Empty(completeImmediately: false).setFailureType(to: CacheError.self).eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + } else { + return CurrentValueSubject(entities).eraseToAnyPublisher() + } + } + + func add(entities: Results) -> AnyPublisher { + return cachePublisher + .flatMap { cache -> AnyPublisher, CacheError> in + if let cache = cache { + let cachedEntities = cache.objects(PaperEntityCache.self) + + let cachedIds = Set(cachedEntities.map({ entity in entity.id })) + + let uncahedEntities = entities.filter("!(id IN %@)", cachedIds) + + return uncahedEntities.collectionPublisher + .mapError { error in + return CacheError.addError(error: error) + } + .eraseToAnyPublisher() + } else { + self.logger.notice("[Cache] Cache nil (filter): \(String(describing: CacheError.realmNilError))") + return Empty(completeImmediately: false).setFailureType(to: CacheError.self).eraseToAnyPublisher() + } + } + .flatMap { uncachedEntities -> AnyPublisher in + var publisherList: [AnyPublisher] = .init() + + uncachedEntities.forEach { entity in + let publisher = Just + .withErrorType(entity, CacheError.self) + .flatMap { entity in + Future { promise in + let pdfURL = constructURL(entity.mainURL) + let entityCache = PaperEntityCache(id: entity.id, fulltext: "") + + DispatchQueue.global(qos: .background).async { + let document = PDFDocument(url: pdfURL!) + if let pdf = document { + entityCache.fulltext = pdf.string ?? "" + DispatchQueue.main.sync { + promise(.success(entityCache)) + } + } else { + DispatchQueue.main.sync { + promise(.success(nil)) + } + } + } + } + } + .flatMap { entityCache in + return self.cachePublisher + .zip( + CurrentValueSubject(entityCache) + .eraseToAnyPublisher() + ) + } + .flatMap { (cache, entityCache) -> AnyPublisher in + if let cache = cache, let entityCache = entityCache { + do { + try cache.safeWrite { + cache.add(entityCache) + } + } catch let error { + print(error) + } + } + return Just + .withErrorType(true, CacheError.self) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + + publisherList.append(publisher) + } + + if publisherList.count > 0 { + return Publishers.MergeMany(publisherList) + .collect(publisherList.count) + .flatMap { _ -> AnyPublisher in + return Just + .withErrorType(true, CacheError.self) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } else { + return Just + .withErrorType(true, CacheError.self) + .eraseToAnyPublisher() + } + + } + .eraseToAnyPublisher() + } + + func delete(ids: [ObjectId]) -> AnyPublisher { + return cachePublisher + .flatMap { cache -> AnyPublisher in + if let cache = cache { + let deleteEntities = cache.objects(PaperEntityCache.self).filter("!(id IN %@)", ids) + + do { + try cache.safeWrite { + cache.delete(deleteEntities) + } + } catch let err { + self.logger.error("Cannot delete entities. \(String(describing: err))") + return Fail(error: CacheError.deleteError(error: err)).eraseToAnyPublisher() + } + return Just + .withErrorType(true, CacheError.self) + .eraseToAnyPublisher() + + } else { + self.logger.notice("[Cache] Cache nil (filter): \(String(describing: CacheError.realmNilError))") + return Empty(completeImmediately: false).setFailureType(to: CacheError.self).eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + } +} diff --git a/PaperLib/Repositories/DBRepository.swift b/PaperLib/Repositories/DBRepository.swift index 343269d0..16e250d9 100644 --- a/PaperLib/Repositories/DBRepository.swift +++ b/PaperLib/Repositories/DBRepository.swift @@ -80,7 +80,8 @@ class RealDBRepository: DBRepository { var config = Realm.Configuration( schemaVersion: self.realmSchemaVersion, - migrationBlock: self.migrate + migrationBlock: self.migrate, + objectTypes: [PaperEntity.self, PaperTag.self, PaperFolder.self] ) let pathURL = URL(string: pathStr ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("paperlib").absoluteString) @@ -115,6 +116,7 @@ class RealDBRepository: DBRepository { ) config.schemaVersion = self.realmSchemaVersion config.migrationBlock = self.migrate + config.objectTypes = [PaperEntity.self, PaperTag.self, PaperFolder.self] self.cloudConfig = config return config @@ -332,11 +334,20 @@ class RealDBRepository: DBRepository { self.sharedState.viewState.entitiesCount.value = results.count var filterFormat = "" - if search != nil { - if !search!.isEmpty { + + if let search = search, !search.isEmpty { + switch self.sharedState.viewState.searchMode.value { + case .general: do { filterFormat += "(title contains[cd] \"\(formatString(search)!)\" OR authors contains[cd] \"\(formatString(search)!)\" OR publication contains[cd] \"\(formatString(search)!)\" OR note contains[cd] \"\(formatString(search)!)\") AND " } + case .fulltext: do { + } + case .advanced: do { + filterFormat += "(\(search.replacingOccurrences(of: "contains", with: "contains[cd]") )) AND " + } + } } + if publication != nil { filterFormat += "(publication contains[cd] \"\(formatString(publication)!)\") AND " } diff --git a/PaperLib/Repositories/FileRepository.swift b/PaperLib/Repositories/FileRepository.swift index 91cd4d07..2dab2fa7 100644 --- a/PaperLib/Repositories/FileRepository.swift +++ b/PaperLib/Repositories/FileRepository.swift @@ -140,17 +140,6 @@ struct RealFileDBRepository: FileRepository { // MARK: - Move - func constructURL(_ path: String) -> URL? { - if path.starts(with: "file://") { - return URL(string: path) - } else { - let dbRoot = UserDefaults.standard.string(forKey: "appLibFolder") ?? "" - var url = URL(string: dbRoot) - url?.appendPathComponent(path) - return url - } - } - func _move(from sourcePath: URL, to targetPath: URL) -> Bool { var isDir: ObjCBool = false if !FileManager.default.fileExists(atPath: targetPath.path) && FileManager.default.fileExists(atPath: sourcePath.path, isDirectory: &isDir) && !isDir.boolValue { @@ -176,8 +165,8 @@ struct RealFileDBRepository: FileRepository { .replacingOccurrences(of: " ", with: "_") + "_\(entity.id)" // 1. Move main file. - let sourceMainURL = self.constructURL(entity.mainURL) - var targetMainURL = self.constructURL(targetFileName + "_main") + let sourceMainURL = constructURL(entity.mainURL) + var targetMainURL = constructURL(targetFileName + "_main") targetMainURL?.appendPathExtension(sourceMainURL?.pathExtension ?? "") if let sourceMainURL = sourceMainURL, let targetMainURL = targetMainURL { let mainSuccess = self._move(from: sourceMainURL, to: targetMainURL) @@ -187,10 +176,10 @@ struct RealFileDBRepository: FileRepository { } // 2. Move sup files - let sourceSupURLs = Array(entity.supURLs).map({ return self.constructURL($0) }) + let sourceSupURLs = Array(entity.supURLs).map({ return constructURL($0) }) var targetSupURLs: [String] = .init() for (i, sourceSupURL) in sourceSupURLs.enumerated() { - var targetSupURL = self.constructURL(targetFileName + "_sup\(i)") + var targetSupURL = constructURL(targetFileName + "_sup\(i)") targetSupURL?.appendPathExtension(sourceSupURL?.pathExtension ?? "") if let sourceSupURL = sourceSupURL, let targetSupURL = targetSupURL { @@ -237,8 +226,8 @@ struct RealFileDBRepository: FileRepository { } func remove(for entity: PaperEntityDraft) -> AnyPublisher { - var fileURLs = entity.supURLs.map({ return self.constructURL($0) }) - fileURLs.insert(self.constructURL(entity.mainURL), at: 0) + var fileURLs = entity.supURLs.map({ return constructURL($0) }) + fileURLs.insert(constructURL(entity.mainURL), at: 0) var publisherList: [AnyPublisher] = .init() fileURLs.forEach { fileURL in @@ -261,7 +250,7 @@ struct RealFileDBRepository: FileRepository { } func remove(for filePath: String) -> AnyPublisher { - if let fileURL = self.constructURL(filePath) { + if let fileURL = constructURL(filePath) { return _remove(for: fileURL) } else { return Just.withErrorType(false, FileError.self) diff --git a/PaperLib/UI/Views/ToolbarView/Components/SearchBar.swift b/PaperLib/UI/Views/ToolbarView/Components/SearchBar.swift index 3d70ec97..83659083 100644 --- a/PaperLib/UI/Views/ToolbarView/Components/SearchBar.swift +++ b/PaperLib/UI/Views/ToolbarView/Components/SearchBar.swift @@ -7,13 +7,88 @@ import SwiftUI +enum SearchMode { + case general + case fulltext + case advanced +} + struct SearchBar: View { + @Environment(\.injected) private var injected: DIContainer + @Binding var text: String + @State var searchMode: SearchMode = .general + @State var searchModeIcon: String = "magnifyingglass" + @State var searchModeText: String = "Search..." + @State var isHelpShown: Bool = false var body: some View { HStack { - Image(systemName: "magnifyingglass") - TextField("Search ...", text: $text) + Button( + action: { + NSApp.keyWindow?.makeFirstResponder(nil) + text = "" + switch searchMode { + case .general: do { + injected.sharedState.viewState.searchMode.value = .fulltext + } + case .fulltext: do { + injected.sharedState.viewState.searchMode.value = .advanced + } + case .advanced: do { + injected.sharedState.viewState.searchMode.value = .general + } + } + }, + label: { + Image(systemName: searchModeIcon) + } + ) + .buttonStyle(PlainButtonStyle()) + .help( + Text(isHelpShown ? """ + +Operators: ==, <, >, <=, >=, !=, in, contains, and, or +Queryable fields: title, authors, publication, pubTime, rating, note + +Examples: +1) Query the paper whos title are 'Test title': + title == 'Test title' + +2) Query the paper whos title contains 'Test title': + title contains 'Test title' + +3) Query the paper whos publication year are 2008: + pubTime == '2008' + +4) Query the paper whos rating are > 3: + rating > 3 + +""" : "") + ) + .onReceive(injected.sharedState.viewState.searchMode.publisher, perform: { _ in + searchMode = injected.sharedState.viewState.searchMode.value + switch searchMode { + case .general: + searchModeText = "Search..." + searchModeIcon = "magnifyingglass" + case .fulltext: + searchModeText = "Fulltext Search..." + searchModeIcon = "text.magnifyingglass" + case .advanced: + searchModeText = "Advanced Search..." + searchModeIcon = "plus.magnifyingglass" + } + }) + .onHover(perform: { isHover in + if isHover && searchMode == .advanced { + isHelpShown = true + } else { + isHelpShown = false + } + }) + + TextField(searchModeText, text: $text) .textFieldStyle(PlainTextFieldStyle()) .frame(width: 300) .multilineTextAlignment(.leading) diff --git a/PaperLib/UI/Views/ToolbarView/ToolbarView.swift b/PaperLib/UI/Views/ToolbarView/ToolbarView.swift index c64149ef..0c5cf4ff 100644 --- a/PaperLib/UI/Views/ToolbarView/ToolbarView.swift +++ b/PaperLib/UI/Views/ToolbarView/ToolbarView.swift @@ -20,7 +20,7 @@ struct ToolbarView: View { var body: some View { SearchBar(text: $viewState.searchText) - .onReceive(viewState.$searchText.debounce(for: .seconds(0.3), scheduler: DispatchQueue.main), perform: { searchText in + .onReceive(viewState.$searchText.debounce(for: .seconds(injected.sharedState.viewState.searchMode.value == .advanced ? 0.6 : 0.3), scheduler: DispatchQueue.main), perform: { searchText in if injected.sharedState.sharedData.searchQuery.value != nil || !searchText.isEmpty { injected.sharedState.sharedData.searchQuery.value = searchText } diff --git a/PaperLib/Utilities/Misc.swift b/PaperLib/Utilities/Misc.swift index 00d4919d..7f254367 100644 --- a/PaperLib/Utilities/Misc.swift +++ b/PaperLib/Utilities/Misc.swift @@ -117,3 +117,14 @@ func getJoinedURL(_ url: String) -> URL? { return nil } } + +func constructURL(_ path: String) -> URL? { + if path.starts(with: "file://") { + return URL(string: path) + } else { + let dbRoot = UserDefaults.standard.string(forKey: "appLibFolder") ?? "" + var url = URL(string: dbRoot) + url?.appendPathComponent(path) + return url + } +}