Sync saved searches on iOS
This commit is contained in:
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"/>
|
||||
|
||||
21
apple/OmnivoreKit/Sources/Models/DataModels/Filter.swift
Normal file
21
apple/OmnivoreKit/Sources/Models/DataModels/Filter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
185
apple/OmnivoreKit/Sources/Models/InboxFilters.swift
Normal file
185
apple/OmnivoreKit/Sources/Models/InboxFilters.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user