diff --git a/App/Backend/History/BrowserHistory.swift b/App/Backend/History/BrowserHistory.swift index 0f3ad97..34f56e3 100644 --- a/App/Backend/History/BrowserHistory.swift +++ b/App/Backend/History/BrowserHistory.swift @@ -7,9 +7,58 @@ import Foundation import CoreData +import Combine class BrowserHistory { + class ViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { + @Published var searchQuery: String = "" + @Published private(set) var historyItems: [HistoryItem] = [] + + fileprivate let historyController: BrowserHistory + fileprivate var searchQueryObserver: AnyCancellable? = nil + fileprivate var fetchedResultsController: NSFetchedResultsController { + didSet { fetchedResultsController.delegate = self; performInitialFetch() } + } + + fileprivate init(fetchedResultsController: NSFetchedResultsController, historyController: BrowserHistory) { + self.fetchedResultsController = fetchedResultsController + self.historyController = historyController + super.init() + + searchQueryObserver = $searchQuery.sink { [unowned self] newValue in + self.fetchedResultsController = historyController.fetchRequestController(forQuery: newValue) + } + } + + public func item(forIdentifier identifier: HistoryItem.ID) -> HistoryItem? { + if let object = fetchedResultsController.managedObjectContext.object(with: identifier) as? HistoryItemEntity { + return HistoryItem(entity: object) + } + + return nil + } + + public func deleteItems(_ items: Set) { + items.forEach { identifier in + historyController.deleteItem(withIdentifier: identifier) + } + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + if let objects = controller.fetchedObjects as? [HistoryItemEntity] { + self.historyItems = objects.map { HistoryItem(entity: $0) } + } + } + + fileprivate func performInitialFetch() { + try? fetchedResultsController.performFetch() + if let objects = fetchedResultsController.fetchedObjects { + self.historyItems = objects.map { HistoryItem(entity: $0) } + } + } + } + static public let shared = BrowserHistory() lazy fileprivate var persistentContainer: NSPersistentContainer = { @@ -37,19 +86,30 @@ class BrowserHistory } } - public func allHistory(limit: Int? = nil) -> [HistoryItem] { - let dataContext = persistentContainer.viewContext - - let fetchRequest: NSFetchRequest = HistoryItemEntity.fetchRequest() - fetchRequest.sortDescriptors = [ - // Sort by date - NSSortDescriptor(keyPath: \HistoryItemEntity.lastVisited, ascending: false) - ] - - if let limit { - fetchRequest.fetchLimit = limit + public func viewModel(forFilterString filterString: String? = nil, limit: Int? = nil) -> ViewModel { + let resultsController = fetchRequestController(forQuery: filterString, limit: limit) + return ViewModel(fetchedResultsController: resultsController, historyController: self) + } + + public func historyItem(forIdentifier: HistoryItem.ID) -> HistoryItem? { + if let entity = try? persistentContainer.viewContext.existingObject(with: forIdentifier) as? HistoryItemEntity { + return HistoryItem(entity: entity) } + return nil + } + + public func deleteItem(withIdentifier identifier: HistoryItem.ID) { + let dataContext = persistentContainer.viewContext + if let object = try? dataContext.existingObject(with: identifier) { + dataContext.delete(object) + try? dataContext.save() + } + } + + public func allHistory(filteredBy filterString: String? = nil, limit: Int? = nil) -> [HistoryItem] { + let dataContext = persistentContainer.viewContext + let fetchRequest = fetchRequest(forStringContaining: filterString, limit: limit) let entities: [HistoryItemEntity] = (try? dataContext.fetch(fetchRequest)) ?? [] return entities.map { (entity) -> HistoryItem in @@ -98,6 +158,38 @@ class BrowserHistory } } +extension BrowserHistory +{ + fileprivate func fetchRequestController(forQuery query: String? = nil, limit: Int? = nil) -> NSFetchedResultsController { + let fetchRequest = fetchRequest(forStringContaining: query, limit: limit) + return NSFetchedResultsController(fetchRequest: fetchRequest, + managedObjectContext: persistentContainer.viewContext, + sectionNameKeyPath: nil, cacheName: nil) + } + + fileprivate func fetchRequest(forStringContaining filterString: String? = nil, limit: Int? = nil) -> NSFetchRequest { + let fetchRequest: NSFetchRequest = HistoryItemEntity.fetchRequest() + fetchRequest.sortDescriptors = [ + // Sort by date + NSSortDescriptor(keyPath: \HistoryItemEntity.lastVisited, ascending: false) + ] + + if let limit { + fetchRequest.fetchLimit = limit + } + + if let filterString, filterString.count > 0 { + fetchRequest.predicate = NSPredicate(format: """ + host CONTAINS[cd] %@ + OR title CONTAINS[cd] %@ + OR url CONTAINS[cd] %@ + """, filterString, filterString, filterString) + } + + return fetchRequest + } +} + extension URL { public func topLevelURL() -> URL { diff --git a/App/Backend/History/HistoryItem.swift b/App/Backend/History/HistoryItem.swift index 2014fb2..7353fba 100644 --- a/App/Backend/History/HistoryItem.swift +++ b/App/Backend/History/HistoryItem.swift @@ -12,13 +12,13 @@ struct HistoryItem: Hashable, Identifiable var url: URL var title: String var lastVisited: Date - var id: ObjectIdentifier + var id: NSManagedObjectID init(entity: HistoryItemEntity) { self.url = entity.url ?? URL(string: "about:blank")! self.lastVisited = entity.lastVisited ?? Date() self.title = entity.title ?? "" - self.id = entity.id + self.id = entity.objectID } // For testing/previews @@ -26,6 +26,6 @@ struct HistoryItem: Hashable, Identifiable self.url = url self.title = title self.lastVisited = lastVisited - self.id = ObjectIdentifier(NSUUID()) + self.id = NSManagedObjectID() } } diff --git a/App/History UI/HistoryBrowserViewController.swift b/App/History UI/HistoryBrowserViewController.swift index b05689f..3862a56 100644 --- a/App/History UI/HistoryBrowserViewController.swift +++ b/App/History UI/HistoryBrowserViewController.swift @@ -11,7 +11,7 @@ import UIKit @MainActor class HistoryBrowserViewController: UIHostingController { public init() { - super.init(rootView: HistoryView(historyItems: BrowserHistory.shared.allHistory(limit: 500))) + super.init(rootView: HistoryView(viewModel: BrowserHistory.shared.viewModel(limit: 500))) } required dynamic init?(coder aDecoder: NSCoder) { diff --git a/App/History UI/HistoryView.swift b/App/History UI/HistoryView.swift index 3cffbb6..1a6f41f 100644 --- a/App/History UI/HistoryView.swift +++ b/App/History UI/HistoryView.swift @@ -9,24 +9,19 @@ import SwiftUI import UniformTypeIdentifiers struct HistoryView: View { - var historyItems: [HistoryItem] - - private let dateFormatter: DateFormatter + @StateObject public var viewModel: BrowserHistory.ViewModel @State public var selectedItems = Set() - @Environment(\.dismiss) private var dismissAction - init(historyItems: [HistoryItem]) { - self.historyItems = historyItems - - let formatter = DateFormatter() - formatter.locale = Locale.current - formatter.dateStyle = .medium - formatter.timeStyle = .short - self.dateFormatter = formatter + private let dateFormatter = DateFormatter() .. { + $0.locale = Locale.current + $0.dateStyle = .medium + $0.timeStyle = .short } + @Environment(\.dismiss) private var dismissAction + var body: some View { - Table(historyItems, selection: $selectedItems) { + Table(viewModel.historyItems, selection: $selectedItems) { TableColumn("Title", value: \.title) TableColumn("URL") { item in @@ -38,34 +33,30 @@ struct HistoryView: View { } } .contextMenu(forSelectionType: HistoryItem.ID.self, menu: { items in - if let firstItem: HistoryItem.ID = items.first, - let historyItem = historyItems.first { $0.id == firstItem } - { - Button("Copy") { - UIPasteboard.general.addItems([ - [ UTType.url.identifier : historyItem.url ] - ]) - } - - // TODO: Delete? + let historyItems = items.compactMap { viewModel.item(forIdentifier: $0) } + Button("Copy") { + UIPasteboard.general.setItems(historyItems.map { [ + UTType.url.identifier : $0.url, + UTType.utf8PlainText.identifier : $0.url.absoluteString, + ] }) + } + + Button("Delete") { + viewModel.deleteItems(items) } }, primaryAction: { items in - if let firstItem: HistoryItem.ID = items.first, - let historyItem = historyItems.first(where: { $0.id == firstItem }) - { - UIApplication.shared.open(historyItem.url) + items.compactMap({ viewModel.item(forIdentifier: $0) }).forEach { item in + UIApplication.shared.open(item.url) dismissAction() } }) + .searchable(text: $viewModel.searchQuery) } } struct HistoryViewPreviewProvider: PreviewProvider { static var previews: some View { - HistoryView(historyItems: [ - HistoryItem(url: URL(string: "https://apple.com")!, title: "Apple", lastVisited: Date.now), - HistoryItem(url: URL(string: "https://google.com")!, title: "Google", lastVisited: Date.now) - ]) + HistoryView(viewModel: BrowserHistory.shared.viewModel()) .previewLayout(.fixed(width: 480.0, height: 800.0)) } } diff --git a/App/Utilities/UIView+Utils.swift b/App/Utilities/UIView+Utils.swift index 5a96570..e3273d3 100644 --- a/App/Utilities/UIView+Utils.swift +++ b/App/Utilities/UIView+Utils.swift @@ -7,6 +7,15 @@ import UIKit +infix operator .. : AssignmentPrecedence + +@discardableResult @inline(__always) @inlinable +public func .. (it: T, apply: (inout T) throws -> Void) rethrows -> T { + var it = it + try apply(&it) + return it +} + protocol Conf { } extension Conf {