diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/FilterSelectorView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/FilterSelectorView.swift deleted file mode 100644 index 614a714cd..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/Home/FilterSelectorView.swift +++ /dev/null @@ -1,121 +0,0 @@ - -import Introspect -import Models -import Services -import SwiftUI -import Views - -@MainActor final class FilterSelectorViewModel: NSObject, ObservableObject { - @Published var isLoading = false - @Published var errorMessage: String = "" - @Published var showErrorMessage: Bool = false - - func error(_ msg: String) { - errorMessage = msg - showErrorMessage = true - isLoading = false - } -} - -struct FilterSelectorView: View { - @ObservedObject var viewModel: HomeFeedViewModel - @ObservedObject var filterViewModel = FilterByLabelsViewModel() - @EnvironmentObject var dataService: DataService - @Environment(\.dismiss) private var dismiss - - @State var showLabelsSheet = false - - init(viewModel: HomeFeedViewModel) { - self.viewModel = viewModel - } - - var body: some View { - Group { - #if os(iOS) - List { - innerBody - } - .listStyle(.grouped) - #elseif os(macOS) - List { - innerBody - } - .listStyle(.plain) - #endif - } - #if os(iOS) - .navigationBarTitle("Library") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: doneButton) - #endif - } - - private var innerBody: some View { - Group { - Section { - ForEach(LinkedItemFilter.allCases, id: \.self) { filter in - HStack { - Text(filter.displayName) - .foregroundColor(viewModel.appliedFilter == filter.rawValue ? Color.blue : Color.appTextDefault) - Spacer() - if viewModel.appliedFilter == filter.rawValue { - Image(systemName: "checkmark") - .foregroundColor(Color.blue) - } - } - .contentShape(Rectangle()) - .onTapGesture { - viewModel.appliedFilter = filter.rawValue - } - } - } - - Section("Labels") { - Button( - action: { - showLabelsSheet = true - }, - label: { - HStack { - Text("Select Labels (\(viewModel.selectedLabels.count))") - Spacer() - Image(systemName: "chevron.right") - } - } - ) - } - } - .sheet(isPresented: $showLabelsSheet) { - FilterByLabelsView( - initiallySelected: viewModel.selectedLabels, - initiallyNegated: viewModel.negatedLabels - ) { - self.viewModel.selectedLabels = $0 - self.viewModel.negatedLabels = $1 - } - } - .task { - await filterViewModel.loadLabels( - dataService: dataService, - initiallySelectedLabels: viewModel.selectedLabels, - initiallyNegatedLabels: viewModel.negatedLabels - ) - } - } - - func isNegated(_ label: LinkedItemLabel) -> Bool { - filterViewModel.negatedLabels.contains(where: { $0.id == label.id }) - } - - func isSelected(_ label: LinkedItemLabel) -> Bool { - filterViewModel.selectedLabels.contains(where: { $0.id == label.id }) - } - - var doneButton: some View { - Button( - action: { dismiss() }, - label: { Text("Done") } - ) - .disabled(viewModel.isLoading) - } -} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift index ebd765dc0..285c020fd 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedDisplayText.swift @@ -2,34 +2,34 @@ import Foundation import Models import Views -extension LinkedItemFilter { - var displayName: String { - switch self { - case .inbox: - return LocalText.inboxGeneric - case .readlater: - return LocalText.readLaterGeneric - case .newsletters: - return LocalText.newslettersGeneric - case .downloaded: - return "Downloaded" - case .feeds: - return "Feeds" - case .recommended: - return "Recommended" - case .all: - return LocalText.allGeneric - case .archived: - return LocalText.archivedGeneric - case .deleted: - return "Deleted" - case .hasHighlights: - return LocalText.highlightedGeneric - case .files: - return LocalText.filesGeneric - } - } -} +// extension InboxFilters { +// var displayName: String { +// switch self { +// case .inbox: +// return LocalText.inboxGeneric +// case .readlater: +// return LocalText.readLaterGeneric +// case .newsletters: +// return LocalText.newslettersGeneric +// case .downloaded: +// return "Downloaded" +// case .feeds: +// return "Feeds" +// case .recommended: +// return "Recommended" +// case .all: +// return LocalText.allGeneric +// case .archived: +// return LocalText.archivedGeneric +// case .deleted: +// return "Deleted" +// case .hasHighlights: +// return LocalText.highlightedGeneric +// case .files: +// return LocalText.filesGeneric +// } +// } +// } public extension LinkedItemSort { var displayName: String { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 6fb4a8864..05dc1a342 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -55,7 +55,7 @@ struct AnimatingCellHeight: AnimatableModifier { viewModel.searchTerm.isEmpty && viewModel.selectedLabels.isEmpty && viewModel.negatedLabels.isEmpty && - LinkedItemFilter(rawValue: viewModel.appliedFilter) == .inbox + viewModel.appliedFilterName == "inbox" } var body: some View { @@ -97,11 +97,6 @@ struct AnimatingCellHeight: AnimatableModifier { .sheet(item: $viewModel.itemForHighlightsView) { item in NotebookView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations) } - .sheet(isPresented: $viewModel.showFiltersModal) { - NavigationView { - FilterSelectorView(viewModel: viewModel) - } - } .sheet(isPresented: $showOpenAIVoices) { OpenAIVoicesModal(audioController: audioController) } @@ -132,8 +127,8 @@ struct AnimatingCellHeight: AnimatableModifier { case let .search(query): viewModel.searchTerm = query case let .savedSearch(named): - if let filter = LinkedItemFilter(rawValue: named) { - viewModel.appliedFilter = filter.rawValue + if let filter = viewModel.findFilter(dataService, named: named) { + viewModel.appliedFilter = filter } case let .webAppLinkRequest(requestID): DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { @@ -169,15 +164,15 @@ struct AnimatingCellHeight: AnimatableModifier { Group { ToolbarItem(placement: .barLeading) { VStack(alignment: .leading) { - let title = (LinkedItemFilter(rawValue: viewModel.appliedFilter) ?? LinkedItemFilter.inbox).displayName + if let title = viewModel.appliedFilter?.name { + Text(title) + .font(Font.system(size: isListScrolled ? 10 : 18, weight: .semibold)) - Text(title) - .font(Font.system(size: isListScrolled ? 10 : 18, weight: .semibold)) - - if prefersListLayout, isListScrolled || !showFeatureCards { - Text(listTitle) - .font(Font.system(size: 15, weight: .regular)) - .foregroundColor(Color.appGrayText) + if prefersListLayout, isListScrolled || !showFeatureCards { + Text(listTitle) + .font(Font.system(size: 15, weight: .regular)) + .foregroundColor(Color.appGrayText) + } } }.frame(maxWidth: .infinity, alignment: .leading) } @@ -362,13 +357,13 @@ struct AnimatingCellHeight: AnimatableModifier { } else { Menu( content: { - ForEach(LinkedItemFilter.allCases, id: \.self) { filter in - Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue }) + ForEach(viewModel.filters) { filter in + Button(filter.name, action: { viewModel.appliedFilter = filter }) } }, label: { TextChipButton.makeMenuButton( - title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter", + title: viewModel.appliedFilter?.name ?? "-", color: .systemGray6 ) } @@ -758,13 +753,13 @@ struct AnimatingCellHeight: AnimatableModifier { } else { Menu( content: { - ForEach(LinkedItemFilter.allCases, id: \.self) { filter in - Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue }) + ForEach(viewModel.filters, id: \.self) { filter in + Button(filter.name, action: { viewModel.appliedFilter = filter }) } }, label: { TextChipButton.makeMenuButton( - title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter", + title: viewModel.appliedFilter?.name ?? "-", color: .systemGray6 ) } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 9aca1e2fa..66b7a52ae 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -40,6 +40,13 @@ import Views @Published var showSnackbar = false @Published var snackbarOperation: SnackbarOperation? + @Published var filters = [InternalFilter]() + @Published var appliedFilter: InternalFilter? { + didSet { + appliedFilterName = appliedFilter?.name.lowercased() ?? "inbox" + } + } + var cursor: String? // These are used to make sure we handle search result @@ -50,7 +57,7 @@ import Views var syncCursor: String? @AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false - @AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue + @AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilterName = "inbox" @AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue init(listConfig: LibraryListConfig) { @@ -112,6 +119,33 @@ import Views } } + func loadFilters(dataService: DataService) async { + var hasLocalResults = false + let fetchRequest: NSFetchRequest = Filter.fetchRequest() + + // Load from disk + if let results = try? dataService.viewContext.fetch(fetchRequest) { + hasLocalResults = true + updateFilters(newFilters: InternalFilter.make(from: results)) + } + + let hasResults = hasLocalResults + Task.detached { + if let downloadedFilters = try? await dataService.filters() { + await self.updateFilters(newFilters: downloadedFilters) + } else if !hasResults { + await self.updateFilters(newFilters: InternalFilter.DefaultFilters) + } + } + } + + func updateFilters(newFilters: [InternalFilter]) { + filters = newFilters.sorted(by: { $0.position < $1.position }) + [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] + if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id { + appliedFilter = newFilter + } + } + func syncItems(dataService: DataService) async { let syncStart = Date.now let lastSyncDate = dataService.lastItemSyncTime @@ -157,9 +191,7 @@ import Views cursor: isRefresh ? nil : cursor ) - let filter = LinkedItemFilter(rawValue: appliedFilter) - - if let queryResult = queryResult { + if let appliedFilter = appliedFilter, let queryResult = queryResult { let newItems: [LinkedItem] = { var itemObjects = [LinkedItem]() dataService.viewContext.performAndWait { @@ -168,7 +200,7 @@ import Views return itemObjects }() - if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, filter?.allowLocalFetch ?? false { + if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, appliedFilter.predicate != nil { updateFetchController(dataService: dataService) } else { // Don't use FRC for searching. Use server results directly. @@ -197,17 +229,19 @@ import Views await withTaskGroup(of: Void.self) { group in group.addTask { await self.loadCurrentViewer(dataService: dataService) } group.addTask { await self.loadLabels(dataService: dataService) } + group.addTask { await self.loadFilters(dataService: dataService) } group.addTask { await self.syncItems(dataService: dataService) } group.addTask { await self.updateFetchController(dataService: dataService) } await group.waitForAll() } - let filter = LinkedItemFilter(rawValue: appliedFilter) - let shouldSearch = items.count < 1 || isRefresh && filter != LinkedItemFilter.downloaded - if shouldSearch { - await loadSearchQuery(dataService: dataService, isRefresh: isRefresh) - } else { - updateFetchController(dataService: dataService) + if let appliedFilter = appliedFilter { + let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch + if shouldRemoteSearch { + await loadSearchQuery(dataService: dataService, isRefresh: isRefresh) + } else { + updateFetchController(dataService: dataService) + } } updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter)) @@ -220,8 +254,7 @@ import Views isLoading = true showLoadingBar = true - let filter = LinkedItemFilter(rawValue: appliedFilter) - if filter != LinkedItemFilter.downloaded { + if let appliedFilter, appliedFilter.shouldRemoteSearch { await loadSearchQuery(dataService: dataService, isRefresh: isRefresh) } @@ -243,7 +276,9 @@ import Views var subPredicates = [NSPredicate]() - subPredicates.append((LinkedItemFilter(rawValue: appliedFilter) ?? .inbox).predicate) + if let predicate = appliedFilter?.predicate { + subPredicates.append(predicate) + } if !selectedLabels.isEmpty { var labelSubPredicates = [NSPredicate]() @@ -382,6 +417,10 @@ import Views } } + func findFilter(_: DataService, named: String) -> InternalFilter? { + filters.first(where: { $0.name == named }) + } + private var queryContainsFilter: Bool { if searchTerm.contains("in:inbox") || searchTerm.contains("in:all") || searchTerm.contains("in:archive") { return true @@ -394,8 +433,8 @@ import Views let sort = LinkedItemSort(rawValue: appliedSort) ?? .newest var query = sort.queryString - if !queryContainsFilter, let filter = LinkedItemFilter(rawValue: appliedFilter) { - query = "\(filter.queryString) \(sort.queryString)" + if !queryContainsFilter, let filter = appliedFilter?.filter { + query = "\(filter) \(sort.queryString)" } if !searchTerm.isEmpty { diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/FiltersView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/FiltersView.swift index 3bfaebd7c..39ebe6966 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/FiltersView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/FiltersView.swift @@ -9,8 +9,21 @@ import Views @Published var isLoading = false @Published var isCreating = false @Published var networkError = false + @Published var libraryFilters = [InternalFilter]() @AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false + + func loadFilters(dataService: DataService) async { + isLoading = true + + do { + libraryFilters = try await dataService.filters() + } catch { + networkError = true + } + + isLoading = false + } } struct FiltersView: View { @@ -31,13 +44,20 @@ struct FiltersView: View { #endif } .navigationTitle(LocalText.filtersGeneric) + .task { await viewModel.loadFilters(dataService: dataService) } } private var innerBody: some View { - Group { + List { Section { Toggle("Hide Feature Section", isOn: $viewModel.hideFeatureSection) } + + Section(header: Text("Saved Searches")) { + ForEach(viewModel.libraryFilters) { filter in + Text(filter.name) + } + } } } } diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 0351d7ee9..5ab4d7d1d 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,5 +1,14 @@ - + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Filter.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Filter.swift new file mode 100644 index 000000000..e2aa4ee07 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Filter.swift @@ -0,0 +1,21 @@ +import CoreData +import Foundation + +public extension Filter { + var unwrappedID: String { id ?? "" } + + static func lookup(byID filterID: String, inContext context: NSManagedObjectContext) -> Filter? { + let fetchRequest: NSFetchRequest = Filter.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "id == %@", filterID + ) + + var filter: Filter? + + context.performAndWait { + filter = (try? context.fetch(fetchRequest))?.first + } + + return filter + } +} diff --git a/apple/OmnivoreKit/Sources/Models/InboxFilters.swift b/apple/OmnivoreKit/Sources/Models/InboxFilters.swift new file mode 100644 index 000000000..123fdba6a --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/InboxFilters.swift @@ -0,0 +1,185 @@ +import Foundation + +// var allowLocalFetch: Bool { +// switch self { +// case .inbox: +// return true +// default: +// return false +// } +// } +// +// var predicate: NSPredicate { +// let undeletedPredicate = NSPredicate( +// format: "%K != %i AND %K != \"DELETED\"", +// #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), +// #keyPath(LinkedItem.state) +// ) +// let notInArchivePredicate = NSPredicate( +// format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber +// ) +// +// switch self { +// case .inbox: +// // non-archived items +// return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate]) +// case .readlater: +// // non-archived or deleted items without the Newsletter label +// let nonNewsletterLabelPredicate = NSPredicate( +// format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0" +// ) +// let nonRSSPredicate = NSPredicate( +// format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [ +// undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate +// ]) +// case .downloaded: +// // include pdf only +// let hasHTMLContent = NSPredicate( +// format: "htmlContent.length > 0" +// ) +// let isPDFPredicate = NSPredicate( +// format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" +// ) +// let localPDFURL = NSPredicate( +// format: "localPDF.length > 0" +// ) +// let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL]) +// return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF]) +// case .newsletters: +// // non-archived or deleted items with the Newsletter label +// let newsletterLabelPredicate = NSPredicate( +// format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate]) +// case .feeds: +// let feedLabelPredicate = NSPredicate( +// format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate]) +// case .recommended: +// // non-archived or deleted items with the Newsletter label +// let recommendedPredicate = NSPredicate( +// format: "recommendations.@count > 0" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendedPredicate]) +// case .all: +// // include everything undeleted +// return undeletedPredicate +// case .archived: +// let inArchivePredicate = NSPredicate( +// format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate]) +// case .deleted: +// let deletedPredicate = NSPredicate( +// format: "%K == %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate]) +// case .files: +// // include pdf only +// let isPDFPredicate = NSPredicate( +// format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate]) +// case .hasHighlights: +// let hasHighlightsPredicate = NSPredicate( +// format: "highlights.@count > 0" +// ) +// return NSCompoundPredicate(andPredicateWithSubpredicates: [ +// hasHighlightsPredicate +// ]) +// } +// } +// } + +public enum FeaturedItemFilter: String, CaseIterable { + case continueReading + case recommended + case newsletters + case pinned +} + +public extension FeaturedItemFilter { + var title: String { + switch self { + case .continueReading: + return "Continue Reading" + case .recommended: + return "Recommended" + case .newsletters: + return "Newsletters" + case .pinned: + return "Pinned" + } + } + + var emptyMessage: String { + switch self { + case .continueReading: + return "Your recently read items will appear here." + case .pinned: + return "Create a label named Pinned and add it to items you would like to appear here." + case .recommended: + return "Reads recommended in your Clubs will appear here." + case .newsletters: + return "All your Newsletters will appear here." + } + } + + var predicate: NSPredicate { + let undeletedPredicate = NSPredicate( + format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) + ) + let notInArchivePredicate = NSPredicate( + format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber + ) + + switch self { + case .continueReading: + // Use > 1 instead of 0 so its only reads they have made slight progress on. + let continueReadingPredicate = NSPredicate( + format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + continueReadingPredicate, undeletedPredicate, notInArchivePredicate + ]) + case .pinned: + let pinnedPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + notInArchivePredicate, undeletedPredicate, pinnedPredicate + ]) + case .newsletters: + // non-archived or deleted items with the Newsletter label + let newsletterLabelPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate + ]) + case .recommended: + // non-archived or deleted items with the Newsletter label + let recommendedPredicate = NSPredicate( + format: "recommendations.@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + notInArchivePredicate, undeletedPredicate, recommendedPredicate + ]) + } + } + + var sortDescriptor: NSSortDescriptor { + let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false) + switch self { + case .continueReading: + return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false) + case .pinned: + return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false) + default: + return savedAtSort + } + } +} diff --git a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift deleted file mode 100644 index 686d5aab5..000000000 --- a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift +++ /dev/null @@ -1,227 +0,0 @@ -import Foundation - -public enum LinkedItemFilter: String, CaseIterable { - case inbox - case feeds - case readlater - case newsletters - case downloaded - case recommended - case all - case archived - case deleted - case hasHighlights - case files -} - -public extension LinkedItemFilter { - var queryString: String { - switch self { - case .inbox: - return "in:inbox" - case .feeds: - return "label:RSS" - case .readlater: - return "in:library" - case .downloaded: - return "" - case .newsletters: - return "in:inbox label:Newsletter" - case .recommended: - return "recommendedBy:*" - case .all: - return "in:all" - case .archived: - return "in:archive" - case .deleted: - return "in:trash" - case .hasHighlights: - return "has:highlights" - case .files: - return "type:file" - } - } - - var allowLocalFetch: Bool { - switch self { - case .inbox: - return true - default: - return false - } - } - - var predicate: NSPredicate { - let undeletedPredicate = NSPredicate( - format: "%K != %i AND %K != \"DELETED\"", - #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), - #keyPath(LinkedItem.state) - ) - let notInArchivePredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber - ) - - switch self { - case .inbox: - // non-archived items - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate]) - case .readlater: - // non-archived or deleted items without the Newsletter label - let nonNewsletterLabelPredicate = NSPredicate( - format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0" - ) - let nonRSSPredicate = NSPredicate( - format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate - ]) - case .downloaded: - // include pdf only - let hasHTMLContent = NSPredicate( - format: "htmlContent.length > 0" - ) - let isPDFPredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" - ) - let localPDFURL = NSPredicate( - format: "localPDF.length > 0" - ) - let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL]) - return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF]) - case .newsletters: - // non-archived or deleted items with the Newsletter label - let newsletterLabelPredicate = NSPredicate( - format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate]) - case .feeds: - let feedLabelPredicate = NSPredicate( - format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate]) - case .recommended: - // non-archived or deleted items with the Newsletter label - let recommendedPredicate = NSPredicate( - format: "recommendations.@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendedPredicate]) - case .all: - // include everything undeleted - return undeletedPredicate - case .archived: - let inArchivePredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate]) - case .deleted: - let deletedPredicate = NSPredicate( - format: "%K == %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate]) - case .files: - // include pdf only - let isPDFPredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate]) - case .hasHighlights: - let hasHighlightsPredicate = NSPredicate( - format: "highlights.@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - hasHighlightsPredicate - ]) - } - } -} - -public enum FeaturedItemFilter: String, CaseIterable { - case continueReading - case recommended - case newsletters - case pinned -} - -public extension FeaturedItemFilter { - var title: String { - switch self { - case .continueReading: - return "Continue Reading" - case .recommended: - return "Recommended" - case .newsletters: - return "Newsletters" - case .pinned: - return "Pinned" - } - } - - var emptyMessage: String { - switch self { - case .continueReading: - return "Your recently read items will appear here." - case .pinned: - return "Create a label named Pinned and add it to items you would like to appear here." - case .recommended: - return "Reads recommended in your Clubs will appear here." - case .newsletters: - return "All your Newsletters will appear here." - } - } - - var predicate: NSPredicate { - let undeletedPredicate = NSPredicate( - format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) - ) - let notInArchivePredicate = NSPredicate( - format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber - ) - - switch self { - case .continueReading: - // Use > 1 instead of 0 so its only reads they have made slight progress on. - let continueReadingPredicate = NSPredicate( - format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - continueReadingPredicate, undeletedPredicate, notInArchivePredicate - ]) - case .pinned: - let pinnedPredicate = NSPredicate( - format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - notInArchivePredicate, undeletedPredicate, pinnedPredicate - ]) - case .newsletters: - // non-archived or deleted items with the Newsletter label - let newsletterLabelPredicate = NSPredicate( - format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate - ]) - case .recommended: - // non-archived or deleted items with the Newsletter label - let recommendedPredicate = NSPredicate( - format: "recommendations.@count > 0" - ) - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - notInArchivePredicate, undeletedPredicate, recommendedPredicate - ]) - } - } - - var sortDescriptor: NSSortDescriptor { - let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false) - switch self { - case .continueReading: - return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false) - case .pinned: - return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false) - default: - return savedAtSort - } - } -} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/FiltersQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/FiltersQuery.swift new file mode 100644 index 000000000..88be3c876 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/FiltersQuery.swift @@ -0,0 +1,52 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func filters() async throws -> [InternalFilter] { + enum QueryResult { + case success(result: [InternalFilter]) + case error(error: String) + } + + let selection = Selection { + try $0.on( + filtersError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }, + filtersSuccess: .init { + QueryResult.success(result: try $0.filters(selection: filterSelection.list)) + } + ) + } + + let query = Selection.Query { + try $0.filters(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + let context = backgroundContext + + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: BasicError.message(messageText: "network request failed")) + return + } + + switch payload.data { + case let .success(result: filters): + if filters.persist(context: context) != nil { + continuation.resume(returning: filters) + } else { + continuation.resume(throwing: BasicError.message(messageText: "CoreData error")) + } + case .error: + continuation.resume(throwing: BasicError.message(messageText: "Filter fetch error")) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift new file mode 100644 index 000000000..da0b723f6 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift @@ -0,0 +1,14 @@ +import Foundation +import Models +import SwiftGraphQL + +let filterSelection = Selection.Filter { + InternalFilter( + id: try $0.id(), + name: try $0.name(), + filter: try $0.filter(), + visible: try $0.visible() ?? true, + position: try $0.position(), + defaultFilter: try $0.defaultFilter() ?? false + ) +} diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift new file mode 100644 index 000000000..b88f6c7e0 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift @@ -0,0 +1,305 @@ +import CoreData +import Foundation +import Models + +public struct InternalFilter: Encodable, Identifiable, Hashable { + public let id: String + public let name: String + public let filter: String + public let visible: Bool + public let position: Int + public let defaultFilter: Bool + + public static var DownloadedFilter: InternalFilter { + InternalFilter( + id: "downloaded", + name: "Downloaded", + filter: "", + visible: true, + position: -1, + defaultFilter: true + ) + } + + public static var DeletedFilter: InternalFilter { + InternalFilter( + id: "deleted", + name: "Deleted", + filter: "in:trash", + visible: true, + position: -1, + defaultFilter: true + ) + } + + public static var DefaultFilters: [InternalFilter] { + [ + InternalFilter( + id: "inbox", + name: "Inbox", + filter: "", + visible: true, + position: 0, + defaultFilter: true + ), + InternalFilter( + id: "non-feed-items", + name: "Non-Feed Items", + filter: "", + visible: true, + position: 1, + defaultFilter: true + ), + InternalFilter( + id: "newsletters", + name: "Newsletters", + filter: "", + visible: true, + position: 2, + defaultFilter: true + ), + InternalFilter( + id: "feeds", + name: "Feeds", + filter: "", + visible: true, + position: 3, + defaultFilter: true + ), + InternalFilter( + id: "archived", + name: "Archived", + filter: "is:archived", + visible: true, + position: 4, + defaultFilter: true + ), + InternalFilter( + id: "files", + name: "Files", + filter: "type:file", + visible: true, + position: 5, + defaultFilter: true + ), + InternalFilter( + id: "highlighted", + name: "Highlights", + filter: "has:highlights", + visible: true, + position: 6, + defaultFilter: true + ), + InternalFilter( + id: "all", + name: "All", + filter: "in:all", + visible: true, + position: 7, + defaultFilter: true + ) + ] + } + + public var shouldRemoteSearch: Bool { + id != "downloaded" + } + + public var isDownloadedFilter: Bool { + id == "downloaded" + } + + public var allowLocalFetch: Bool { + true + } + + public var predicate: NSPredicate? { + if !defaultFilter { + return nil + } + + let undeletedPredicate = NSPredicate( + format: "%K != %i AND %K != \"DELETED\"", + #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue), + #keyPath(LinkedItem.state) + ) + let notInArchivePredicate = NSPredicate( + format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber + ) + + switch name { + case "Inbox": + // non-archived items + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate]) + case "Non-Feed Items": + // non-archived or deleted items without the Newsletter label + let nonNewsletterLabelPredicate = NSPredicate( + format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0" + ) + let nonRSSPredicate = NSPredicate( + format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate + ]) + case "Downloaded": + // include pdf only + let hasHTMLContent = NSPredicate( + format: "htmlContent.length > 0" + ) + let isPDFPredicate = NSPredicate( + format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" + ) + let localPDFURL = NSPredicate( + format: "localPDF.length > 0" + ) + let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL]) + return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF]) + case "Newsletters": + // non-archived or deleted items with the Newsletter label + let newsletterLabelPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate]) + case "Feeds": + let feedLabelPredicate = NSPredicate( + format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate]) + case "Recommended": + // non-archived or deleted items with the Newsletter label + let recommendedPredicate = NSPredicate( + format: "recommendations.@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendedPredicate]) + case "All": + // include everything undeleted + return undeletedPredicate + case "Archived": + let inArchivePredicate = NSPredicate( + format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate]) + case "Deleted": + let deletedPredicate = NSPredicate( + format: "%K == %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate]) + case "Files": + // include pdf only + let isPDFPredicate = NSPredicate( + format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate]) + case "Highlights": + let hasHighlightsPredicate = NSPredicate( + format: "highlights.@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + hasHighlightsPredicate + ]) + default: + return nil + } + } + + func persist(context: NSManagedObjectContext) -> NSManagedObjectID? { + var objectID: NSManagedObjectID? + + context.performAndWait { + let filter = asManagedObject(inContext: context) + + do { + try context.save() + logger.debug("LinkedItemLabel saved succesfully") + objectID = filter.objectID + } catch { + context.rollback() + logger.debug("Failed to save LinkedItemLabel: \(error.localizedDescription)") + } + } + + return objectID + } + + func asManagedObject(inContext context: NSManagedObjectContext) -> Filter { + let existing = Filter.lookup(byID: id, inContext: context) + let newFilter = existing ?? Filter(entity: Filter.entity(), insertInto: context) + newFilter.id = id + newFilter.name = name + newFilter.filter = filter + newFilter.visible = visible + newFilter.position = Int64(position) + newFilter.defaultFilter = defaultFilter + return newFilter + } + + public static func make(from filters: [Filter]) -> [InternalFilter] { + filters.compactMap { filter in + if let id = filter.id, + let name = filter.name, + let filterStr = filter.filter + { + return InternalFilter( + id: id, + name: name, + filter: filterStr, + visible: filter.visible, + position: Int(filter.position), + defaultFilter: filter.defaultFilter + ) + } + return nil + } + } +} + +public extension Filter { + var unwrappedID: String { id ?? "" } + + static func lookup(byID id: String, inContext context: NSManagedObjectContext) -> Filter? { + let fetchRequest: NSFetchRequest = Filter.fetchRequest() + fetchRequest.predicate = NSPredicate( + format: "id == %@", id + ) + + var filter: Filter? + + context.performAndWait { + filter = (try? context.fetch(fetchRequest))?.first + } + + return filter + } +} + +extension Sequence where Element == InternalFilter { + func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? { + var result: [NSManagedObjectID]? + + context.performAndWait { + let fetchRequest: NSFetchRequest = Filter.fetchRequest() + let existing = (try? fetchRequest.execute()) ?? [] + + let validLabelIDs = map(\.id) + let invalid = existing.filter { !validLabelIDs.contains($0.unwrappedID) } + + for filter in invalid { + context.delete(filter) + } + + let filters = map { $0.asManagedObject(inContext: context) } + + do { + try context.save() + logger.debug("filters saved succesfully") + result = filters.map(\.objectID) + } catch { + context.rollback() + logger.debug("Failed to save filters: \(error.localizedDescription)") + } + } + + return result + } +}