Sync saved searches on iOS

This commit is contained in:
Jackson Harper
2023-11-27 21:44:52 +08:00
parent ee3b14e46d
commit bf1561887d
12 changed files with 708 additions and 416 deletions

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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
)
}

View File

@ -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<Models.Filter> = 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 {

View File

@ -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)
}
}
}
}
}

View File

@ -1,5 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="22G74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Filter" representedClassName="Filter" syncable="YES" codeGenerationType="class">
<attribute name="defaultFilter" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="filter" optional="YES" attributeType="String"/>
<attribute name="folder" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="position" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="visible" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
</entity>
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
<attribute name="annotation" optional="YES" attributeType="String"/>
<attribute name="color" optional="YES" attributeType="String"/>

View File

@ -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<Models.Filter> = Filter.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "id == %@", filterID
)
var filter: Filter?
context.performAndWait {
filter = (try? context.fetch(fetchRequest))?.first
}
return filter
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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<QueryResult, Unions.FiltersResult> {
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"))
}
}
}
}
}

View File

@ -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
)
}

View File

@ -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<Models.Filter> = 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<Models.Filter> = 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
}
}