diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/FetcherFilterState.swift b/apple/OmnivoreKit/Sources/App/Views/Home/FetcherFilterState.swift index 15c1d8d0c..4ed322200 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/FetcherFilterState.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/FetcherFilterState.swift @@ -12,18 +12,10 @@ class FetcherFilterState: ObservableObject { @Published var selectedLabels = [LinkedItemLabel]() @Published var negatedLabels = [LinkedItemLabel]() + @Published var appliedFilter: InternalFilter? @Published var appliedSort = LinkedItemSort.newest.rawValue - @Published var appliedFilter: InternalFilter? { - didSet { - let newValue = appliedFilter?.name.lowercased() - UserDefaults.standard.setValue(newValue, forKey: "lastSelected-\(folder)-filter") - } - } - init(folder: String) { self.folder = folder - let newValue = appliedFilter?.name.lowercased() - let appliedFilterKey = UserDefaults.standard.string(forKey: "lastSelected-\(folder)-filter") } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 599322e89..bfcdd01bb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -43,20 +43,19 @@ struct AnimatingCellHeight: AnimatableModifier { @ObservedObject var viewModel: HomeFeedViewModel @State private var selection = Set() - @ObservedObject var filterState: FetcherFilterState func loadItems(isRefresh: Bool) { - Task { await viewModel.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) } + Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) } } var showFeatureCards: Bool { viewModel.listConfig.hasFeatureCards && !viewModel.hideFeatureSection && viewModel.fetcher.items.count > 0 && - filterState.searchTerm.isEmpty && - filterState.selectedLabels.isEmpty && - filterState.negatedLabels.isEmpty && - filterState.appliedFilter?.name == "inbox" + viewModel.filterState.searchTerm.isEmpty && + viewModel.filterState.selectedLabels.isEmpty && + viewModel.filterState.negatedLabels.isEmpty && + viewModel.filterState.appliedFilter?.name == "inbox" } var body: some View { @@ -67,27 +66,26 @@ struct AnimatingCellHeight: AnimatableModifier { isEditMode: $isEditMode, selection: $selection, viewModel: viewModel, - filterState: filterState, showFeatureCards: showFeatureCards ) .refreshable { loadItems(isRefresh: true) } - .onChange(of: filterState.searchTerm) { _ in + .onChange(of: viewModel.filterState.searchTerm) { _ in // Maybe we should debounce this, but // it feels like it works ok without loadItems(isRefresh: true) } - .onChange(of: filterState.selectedLabels) { _ in + .onChange(of: viewModel.filterState.selectedLabels) { _ in loadItems(isRefresh: true) } - .onChange(of: filterState.negatedLabels) { _ in + .onChange(of: viewModel.filterState.negatedLabels) { _ in loadItems(isRefresh: true) } - .onChange(of: filterState.appliedFilter) { _ in + .onChange(of: viewModel.filterState.appliedFilter) { _ in loadItems(isRefresh: true) } - .onChange(of: filterState.appliedSort) { _ in + .onChange(of: viewModel.filterState.appliedSort) { _ in loadItems(isRefresh: true) } .sheet(item: $viewModel.itemUnderLabelEdit) { item in @@ -127,10 +125,10 @@ struct AnimatingCellHeight: AnimatableModifier { if let deepLink = DeepLink.make(from: url) { switch deepLink { case let .search(query): - filterState.searchTerm = query + viewModel.filterState.searchTerm = query case let .savedSearch(named): if let filter = viewModel.findFilter(dataService, named: named) { - filterState.appliedFilter = filter + viewModel.filterState.appliedFilter = filter } case let .webAppLinkRequest(requestID): DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { @@ -158,6 +156,7 @@ struct AnimatingCellHeight: AnimatableModifier { if viewModel.fetcher.items.isEmpty { loadItems(isRefresh: false) } + await viewModel.loadFilters(dataService: dataService, filterState: viewModel.filterState) } .environment(\.editMode, self.$isEditMode) } @@ -167,7 +166,7 @@ struct AnimatingCellHeight: AnimatableModifier { ToolbarItem(placement: .barLeading) { VStack(alignment: .leading) { let showDate = isListScrolled && !listTitle.isEmpty - if let title = filterState.appliedFilter?.name { + if let title = viewModel.filterState.appliedFilter?.name { Text(title) .font(Font.system(size: showDate ? 10 : 18, weight: .semibold)) if showDate, prefersListLayout, isListScrolled || !showFeatureCards { @@ -264,7 +263,6 @@ struct AnimatingCellHeight: AnimatableModifier { @Binding var isEditMode: EditMode @Binding var selection: Set @ObservedObject var viewModel: HomeFeedViewModel - @ObservedObject var filterState: FetcherFilterState let showFeatureCards: Bool @@ -294,23 +292,21 @@ struct AnimatingCellHeight: AnimatableModifier { isEditMode: $isEditMode, selection: $selection, viewModel: viewModel, - filterState: filterState, showFeatureCards: showFeatureCards ) } else { HomeFeedGridView( viewModel: viewModel, - filterState: filterState, isListScrolled: $isListScrolled ) } }.sheet(isPresented: $viewModel.showLabelsSheet) { FilterByLabelsView( - initiallySelected: filterState.selectedLabels, - initiallyNegated: filterState.negatedLabels + initiallySelected: viewModel.filterState.selectedLabels, + initiallyNegated: viewModel.filterState.negatedLabels ) { - self.filterState.selectedLabels = $0 - self.filterState.negatedLabels = $1 + viewModel.filterState.selectedLabels = $0 + viewModel.filterState.negatedLabels = $1 } } .popup(isPresented: $viewModel.showSnackbar) { @@ -351,7 +347,6 @@ struct AnimatingCellHeight: AnimatableModifier { @Binding var selection: Set @ObservedObject var viewModel: HomeFeedViewModel - @ObservedObject var filterState: FetcherFilterState let showFeatureCards: Bool @@ -359,20 +354,20 @@ struct AnimatingCellHeight: AnimatableModifier { GeometryReader { reader in ScrollView(.horizontal, showsIndicators: false) { HStack { - if filterState.searchTerm.count > 0 { - TextChipButton.makeSearchFilterButton(title: filterState.searchTerm) { - filterState.searchTerm = "" + if viewModel.filterState.searchTerm.count > 0 { + TextChipButton.makeSearchFilterButton(title: viewModel.filterState.searchTerm) { + viewModel.filterState.searchTerm = "" }.frame(maxWidth: reader.size.width * 0.66) } else { Menu( content: { ForEach(viewModel.filters) { filter in - Button(filter.name, action: { filterState.appliedFilter = filter }) + Button(filter.name, action: { viewModel.filterState.appliedFilter = filter }) } }, label: { TextChipButton.makeMenuButton( - title: filterState.appliedFilter?.name ?? "-", + title: viewModel.filterState.appliedFilter?.name ?? "-", color: .systemGray6 ) } @@ -381,25 +376,25 @@ struct AnimatingCellHeight: AnimatableModifier { Menu( content: { ForEach(LinkedItemSort.allCases, id: \.self) { sort in - Button(sort.displayName, action: { filterState.appliedSort = sort.rawValue }) + Button(sort.displayName, action: { viewModel.filterState.appliedSort = sort.rawValue }) } }, label: { TextChipButton.makeMenuButton( - title: LinkedItemSort(rawValue: filterState.appliedSort)?.displayName ?? "Sort", + title: LinkedItemSort(rawValue: viewModel.filterState.appliedSort)?.displayName ?? "Sort", color: .systemGray6 ) } ) TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true }) - ForEach(filterState.selectedLabels, id: \.self) { label in + ForEach(viewModel.filterState.selectedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) { - filterState.selectedLabels.removeAll { $0.id == label.id } + viewModel.filterState.selectedLabels.removeAll { $0.id == label.id } } } - ForEach(filterState.negatedLabels, id: \.self) { label in + ForEach(viewModel.filterState.negatedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) { - filterState.negatedLabels.removeAll { $0.id == label.id } + viewModel.filterState.negatedLabels.removeAll { $0.id == label.id } } } Spacer() @@ -569,7 +564,7 @@ struct AnimatingCellHeight: AnimatableModifier { .listRowSeparator(.hidden, edges: .all) .listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset)) - if let appliedFilter = filterState.appliedFilter, + if let appliedFilter = viewModel.filterState.appliedFilter, networkMonitor.status == .disconnected, !appliedFilter.allowLocalFetch { @@ -706,7 +701,6 @@ struct AnimatingCellHeight: AnimatableModifier { @State var isContextMenuOpen = false @ObservedObject var viewModel: HomeFeedViewModel - @ObservedObject var filterState: FetcherFilterState @Binding var isListScrolled: Bool @@ -726,27 +720,27 @@ struct AnimatingCellHeight: AnimatableModifier { } func loadItems(isRefresh: Bool) { - Task { await viewModel.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) } + Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) } } var filtersHeader: some View { GeometryReader { reader in ScrollView(.horizontal, showsIndicators: false) { HStack { - if filterState.searchTerm.count > 0 { - TextChipButton.makeSearchFilterButton(title: filterState.searchTerm) { - filterState.searchTerm = "" + if viewModel.filterState.searchTerm.count > 0 { + TextChipButton.makeSearchFilterButton(title: viewModel.filterState.searchTerm) { + viewModel.filterState.searchTerm = "" }.frame(maxWidth: reader.size.width * 0.66) } else { Menu( content: { ForEach(viewModel.filters, id: \.self) { filter in - Button(filter.name, action: { filterState.appliedFilter = filter }) + Button(filter.name, action: { viewModel.filterState.appliedFilter = filter }) } }, label: { TextChipButton.makeMenuButton( - title: filterState.appliedFilter?.name ?? "-", + title: viewModel.filterState.appliedFilter?.name ?? "-", color: .systemGray6 ) } @@ -755,25 +749,25 @@ struct AnimatingCellHeight: AnimatableModifier { Menu( content: { ForEach(LinkedItemSort.allCases, id: \.self) { sort in - Button(sort.displayName, action: { filterState.appliedSort = sort.rawValue }) + Button(sort.displayName, action: { viewModel.filterState.appliedSort = sort.rawValue }) } }, label: { TextChipButton.makeMenuButton( - title: LinkedItemSort(rawValue: filterState.appliedSort)?.displayName ?? "Sort", + title: LinkedItemSort(rawValue: viewModel.filterState.appliedSort)?.displayName ?? "Sort", color: .systemGray6 ) } ) TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true }) - ForEach(filterState.selectedLabels, id: \.self) { label in + ForEach(viewModel.filterState.selectedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) { - filterState.selectedLabels.removeAll { $0.id == label.id } + viewModel.filterState.selectedLabels.removeAll { $0.id == label.id } } } - ForEach(filterState.negatedLabels, id: \.self) { label in + ForEach(viewModel.filterState.negatedLabels, id: \.self) { label in TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) { - filterState.negatedLabels.removeAll { $0.id == label.id } + viewModel.filterState.negatedLabels.removeAll { $0.id == label.id } } } Spacer() diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 576e90994..a036cbfa0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -37,14 +37,17 @@ import Views @Published var filters = [InternalFilter]() + @ObservedObject var filterState: FetcherFilterState + @AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false @AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue let fetcher: LibraryItemFetcher - init(fetcher: LibraryItemFetcher, listConfig: LibraryListConfig) { + init(fetcher: LibraryItemFetcher, filterState: FetcherFilterState, listConfig: LibraryListConfig) { self.fetcher = fetcher self.listConfig = listConfig + self.filterState = filterState super.init() } @@ -64,7 +67,32 @@ import Views } } - func itemAppeared(item: Models.LibraryItem, dataService _: DataService) async { + func loadFilters(dataService: DataService, filterState: FetcherFilterState) async { + switch filterState.folder { + case "following": + updateFilters(filterState: filterState, newFilters: InternalFilter.DefaultFollowingFilters) + default: + var hasLocalResults = false + let fetchRequest: NSFetchRequest = Filter.fetchRequest() + + // Load from disk + if let results = try? dataService.viewContext.fetch(fetchRequest) { + hasLocalResults = true + updateFilters(filterState: filterState, newFilters: InternalFilter.make(from: results)) + } + + let hasResults = hasLocalResults + Task.detached { + if let downloadedFilters = try? await dataService.filters() { + await self.updateFilters(filterState: filterState, newFilters: downloadedFilters) + } else if !hasResults { + await self.updateFilters(filterState: filterState, newFilters: InternalFilter.DefaultInboxFilters) + } + } + } + } + + func itemAppeared(item: Models.LibraryItem, dataService: DataService) async { if isLoading { return } let itemIndex = fetcher.items.firstIndex(where: { $0.id == item.id }) let thresholdIndex = fetcher.items.index(fetcher.items.endIndex, offsetBy: -5) @@ -72,9 +100,9 @@ import Views // Check if user has scrolled to the last five items in the list // Make sure we aren't currently loading though, as this would get triggered when the first set // of items are presented to the user. -// if let itemIndex = itemIndex, itemIndex > thresholdIndex { -// await loadMoreItems(dataService: dataService, isRefresh: false) -// } + if let itemIndex = itemIndex, itemIndex > thresholdIndex { + await loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: false) + } } func pushFeedItem(item _: Models.LibraryItem) { @@ -98,38 +126,23 @@ import Views } } - func loadFilters(dataService: DataService) async { - var hasLocalResults = false - let fetchRequest: NSFetchRequest = Filter.fetchRequest() + func updateFilters(filterState: FetcherFilterState, newFilters: [InternalFilter]) { + let appliedFilterName = UserDefaults.standard.string(forKey: "lastSelected-\(filterState.folder)-filter") ?? filterState.folder - // Load from disk - if let results = try? dataService.viewContext.fetch(fetchRequest) { - hasLocalResults = true - updateFilters(newFilters: InternalFilter.make(from: results)) - } + filters = newFilters + .filter { $0.folder == filterState.folder } + .sorted(by: { $0.position < $1.position }) + + [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] - 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) - } + if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != filterState.appliedFilter?.id { + filterState.appliedFilter = newFilter } } - 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 loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async { + func loadItems(dataService: DataService, isRefresh: Bool) async { isLoading = true showLoadingBar = true - // group.addTask { await self.loadFilters(dataService: dataService) } await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter)) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift index f3eb86b1c..5fa1372c1 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift @@ -6,17 +6,13 @@ import Views struct HomeView: View { @State private var viewModel: HomeFeedViewModel - let inboxFilterState = FetcherFilterState( - folder: "inbox" - ) - init(viewModel: HomeFeedViewModel) { self.viewModel = viewModel } var body: some View { #if os(iOS) - HomeFeedContainerView(viewModel: viewModel, filterState: inboxFilterState) + HomeFeedContainerView(viewModel: viewModel) #elseif os(macOS) HomeFeedView(viewModel: viewModel) .frame(minWidth: 320) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift index c28104145..29811b6f8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibraryListView.swift @@ -12,6 +12,7 @@ import SwiftUI struct LibraryListView: View { @StateObject private var libraryViewModel = HomeFeedViewModel( fetcher: LibraryItemFetcher(), + filterState: FetcherFilterState(folder: "inbox"), listConfig: LibraryListConfig( hasFeatureCards: true, leadingSwipeActions: [.pin], diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index b2e8ce2e1..130933b9e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -25,6 +25,7 @@ struct LibraryTabView: View { @StateObject private var followingViewModel = HomeFeedViewModel( fetcher: LibraryItemFetcher(), + filterState: FetcherFilterState(folder: "following"), listConfig: LibraryListConfig( hasFeatureCards: false, leadingSwipeActions: [.moveToInbox], @@ -35,6 +36,7 @@ struct LibraryTabView: View { @StateObject private var libraryViewModel = HomeFeedViewModel( fetcher: LibraryItemFetcher(), + filterState: FetcherFilterState(folder: "inbox"), listConfig: LibraryListConfig( hasFeatureCards: true, leadingSwipeActions: [.pin], @@ -47,19 +49,13 @@ struct LibraryTabView: View { VStack(spacing: 0) { TabView(selection: $selectedTab) { NavigationView { - HomeFeedContainerView( - viewModel: followingViewModel, - filterState: FetcherFilterState(folder: "following") - ) - .navigationViewStyle(.stack) + HomeFeedContainerView(viewModel: followingViewModel) + .navigationViewStyle(.stack) }.tag("following") NavigationView { - HomeFeedContainerView( - viewModel: libraryViewModel, - filterState: FetcherFilterState(folder: "inbox") - ) - .navigationViewStyle(.stack) + HomeFeedContainerView(viewModel: libraryViewModel) + .navigationViewStyle(.stack) }.tag("inbox") NavigationView { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index f3cf55737..1a01c1269 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -5643,6 +5643,7 @@ extension Objects { let image: [String: String] let publishedAt: [String: DateTime] let title: [String: String] + let type: [String: String] let updatedAt: [String: DateTime] let url: [String: String] @@ -5692,6 +5693,10 @@ extension Objects.Feed: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "type": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "updatedAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -5717,6 +5722,7 @@ extension Objects.Feed: Decodable { image = map["image"] publishedAt = map["publishedAt"] title = map["title"] + type = map["type"] updatedAt = map["updatedAt"] url = map["url"] } @@ -5738,7 +5744,7 @@ extension Fields where TypeLock == Objects.Feed { } } - func createdAt() throws -> DateTime { + func createdAt() throws -> DateTime? { let field = GraphQLField.leaf( name: "createdAt", arguments: [] @@ -5747,12 +5753,9 @@ extension Fields where TypeLock == Objects.Feed { switch response { case let .decoding(data): - if let data = data.createdAt[field.alias!] { - return data - } - throw HttpError.badpayload + return data.createdAt[field.alias!] case .mocking: - return DateTime.mockValue + return nil } } @@ -5771,7 +5774,7 @@ extension Fields where TypeLock == Objects.Feed { } } - func id() throws -> String { + func id() throws -> String? { let field = GraphQLField.leaf( name: "id", arguments: [] @@ -5780,12 +5783,9 @@ extension Fields where TypeLock == Objects.Feed { switch response { case let .decoding(data): - if let data = data.id[field.alias!] { - return data - } - throw HttpError.badpayload + return data.id[field.alias!] case .mocking: - return String.mockValue + return nil } } @@ -5837,7 +5837,22 @@ extension Fields where TypeLock == Objects.Feed { } } - func updatedAt() throws -> DateTime { + func type() throws -> String? { + let field = GraphQLField.leaf( + name: "type", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.type[field.alias!] + case .mocking: + return nil + } + } + + func updatedAt() throws -> DateTime? { let field = GraphQLField.leaf( name: "updatedAt", arguments: [] @@ -5846,12 +5861,9 @@ extension Fields where TypeLock == Objects.Feed { switch response { case let .decoding(data): - if let data = data.updatedAt[field.alias!] { - return data - } - throw HttpError.badpayload + return data.updatedAt[field.alias!] case .mocking: - return DateTime.mockValue + return nil } } @@ -6643,11 +6655,11 @@ extension Selection where TypeLock == Never, Type == Never { extension Objects { struct Filter { let __typename: TypeName = .filter - let category: [String: String] let createdAt: [String: DateTime] let defaultFilter: [String: Bool] let description: [String: String] let filter: [String: String] + let folder: [String: String] let id: [String: String] let name: [String: String] let position: [String: Int] @@ -6672,10 +6684,6 @@ extension Objects.Filter: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { - case "category": - if let value = try container.decode(String?.self, forKey: codingKey) { - map.set(key: field, hash: alias, value: value as Any) - } case "createdAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -6692,6 +6700,10 @@ extension Objects.Filter: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "folder": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "id": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -6722,11 +6734,11 @@ extension Objects.Filter: Decodable { } } - category = map["category"] createdAt = map["createdAt"] defaultFilter = map["defaultFilter"] description = map["description"] filter = map["filter"] + folder = map["folder"] id = map["id"] name = map["name"] position = map["position"] @@ -6736,24 +6748,6 @@ extension Objects.Filter: Decodable { } extension Fields where TypeLock == Objects.Filter { - func category() throws -> String { - let field = GraphQLField.leaf( - name: "category", - arguments: [] - ) - select(field) - - switch response { - case let .decoding(data): - if let data = data.category[field.alias!] { - return data - } - throw HttpError.badpayload - case .mocking: - return String.mockValue - } - } - func createdAt() throws -> DateTime { let field = GraphQLField.leaf( name: "createdAt", @@ -6820,6 +6814,24 @@ extension Fields where TypeLock == Objects.Filter { } } + func folder() throws -> String { + let field = GraphQLField.leaf( + name: "folder", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.folder[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + func id() throws -> String { let field = GraphQLField.leaf( name: "id", @@ -13695,6 +13707,7 @@ extension Objects { let recentEmails: [String: Unions.RecentEmailsResult] let recentSearches: [String: Unions.RecentSearchesResult] let rules: [String: Unions.RulesResult] + let scanFeeds: [String: Unions.ScanFeedsResult] let search: [String: Unions.SearchResult] let sendInstallInstructions: [String: Unions.SendInstallInstructionsResult] let subscriptions: [String: Unions.SubscriptionsResult] @@ -13788,6 +13801,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.RulesResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "scanFeeds": + if let value = try container.decode(Unions.ScanFeedsResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "search": if let value = try container.decode(Unions.SearchResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -13854,6 +13871,7 @@ extension Objects.Query: Decodable { recentEmails = map["recentEmails"] recentSearches = map["recentSearches"] rules = map["rules"] + scanFeeds = map["scanFeeds"] search = map["search"] sendInstallInstructions = map["sendInstallInstructions"] subscriptions = map["subscriptions"] @@ -14165,6 +14183,25 @@ extension Fields where TypeLock == Objects.Query { } } + func scanFeeds(input: InputObjects.ScanFeedsInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "scanFeeds", + arguments: [Argument(name: "input", type: "ScanFeedsInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.scanFeeds[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func search(after: OptionalArgument = .absent(), first: OptionalArgument = .absent(), format: OptionalArgument = .absent(), includeContent: OptionalArgument = .absent(), query: OptionalArgument = .absent(), selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "search", @@ -14241,10 +14278,10 @@ extension Fields where TypeLock == Objects.Query { } } - func updatesSince(after: OptionalArgument = .absent(), first: OptionalArgument = .absent(), since: DateTime, sort: OptionalArgument = .absent(), selection: Selection) throws -> Type { + func updatesSince(after: OptionalArgument = .absent(), first: OptionalArgument = .absent(), folder: OptionalArgument = .absent(), since: DateTime, sort: OptionalArgument = .absent(), selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "updatesSince", - arguments: [Argument(name: "after", type: "String", value: after), Argument(name: "first", type: "Int", value: first), Argument(name: "since", type: "Date!", value: since), Argument(name: "sort", type: "SortParams", value: sort)], + arguments: [Argument(name: "after", type: "String", value: after), Argument(name: "first", type: "Int", value: first), Argument(name: "folder", type: "String", value: folder), Argument(name: "since", type: "Date!", value: since), Argument(name: "sort", type: "SortParams", value: sort)], selection: selection.selection ) select(field) @@ -17454,6 +17491,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveSuccess = Selection } +extension Objects { + struct ScanFeedsError { + let __typename: TypeName = .scanFeedsError + let errorCodes: [String: [Enums.ScanFeedsErrorCode]] + + enum TypeName: String, Codable { + case scanFeedsError = "ScanFeedsError" + } + } +} + +extension Objects.ScanFeedsError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.ScanFeedsErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.ScanFeedsError { + func errorCodes() throws -> [Enums.ScanFeedsErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ScanFeedsError = Selection +} + +extension Objects { + struct ScanFeedsSuccess { + let __typename: TypeName = .scanFeedsSuccess + let feeds: [String: [Objects.Feed]] + + enum TypeName: String, Codable { + case scanFeedsSuccess = "ScanFeedsSuccess" + } + } +} + +extension Objects.ScanFeedsSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "feeds": + if let value = try container.decode([Objects.Feed]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + feeds = map["feeds"] + } +} + +extension Fields where TypeLock == Objects.ScanFeedsSuccess { + func feeds(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "feeds", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feeds[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ScanFeedsSuccess = Selection +} + extension Objects { struct SearchError { let __typename: TypeName = .searchError @@ -29856,6 +30024,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveResult = Selection } +extension Unions { + struct ScanFeedsResult { + let __typename: TypeName + let errorCodes: [String: [Enums.ScanFeedsErrorCode]] + let feeds: [String: [Objects.Feed]] + + enum TypeName: String, Codable { + case scanFeedsError = "ScanFeedsError" + case scanFeedsSuccess = "ScanFeedsSuccess" + } + } +} + +extension Unions.ScanFeedsResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.ScanFeedsErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feeds": + if let value = try container.decode([Objects.Feed]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + feeds = map["feeds"] + } +} + +extension Fields where TypeLock == Unions.ScanFeedsResult { + func on(scanFeedsError: Selection, scanFeedsSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "ScanFeedsError", selection: scanFeedsError.selection), GraphQLField.fragment(type: "ScanFeedsSuccess", selection: scanFeedsSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .scanFeedsError: + let data = Objects.ScanFeedsError(errorCodes: data.errorCodes) + return try scanFeedsError.decode(data: data) + case .scanFeedsSuccess: + let data = Objects.ScanFeedsSuccess(feeds: data.feeds) + return try scanFeedsSuccess.decode(data: data) + } + case .mocking: + return scanFeedsError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ScanFeedsResult = Selection +} + extension Unions { struct SearchResult { let __typename: TypeName @@ -33434,6 +33676,22 @@ extension Enums { } } +extension Enums { + /// ScanFeedsErrorCode + enum ScanFeedsErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + } +} + +extension Enums { + /// ScanFeedsType + enum ScanFeedsType: String, CaseIterable, Codable { + case html = "HTML" + + case opml = "OPML" + } +} + extension Enums { /// SearchErrorCode enum SearchErrorCode: String, CaseIterable, Codable { @@ -33997,6 +34255,8 @@ extension InputObjects { struct CreateArticleInput: Encodable, Hashable { var articleSavingRequestId: OptionalArgument = .absent() + var folder: OptionalArgument = .absent() + var labels: OptionalArgument<[InputObjects.CreateLabelInput]> = .absent() var preparedDocument: OptionalArgument = .absent() @@ -34014,6 +34274,7 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) if articleSavingRequestId.hasValue { try container.encode(articleSavingRequestId, forKey: .articleSavingRequestId) } + if folder.hasValue { try container.encode(folder, forKey: .folder) } if labels.hasValue { try container.encode(labels, forKey: .labels) } if preparedDocument.hasValue { try container.encode(preparedDocument, forKey: .preparedDocument) } if skipParsing.hasValue { try container.encode(skipParsing, forKey: .skipParsing) } @@ -34025,6 +34286,7 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case articleSavingRequestId + case folder case labels case preparedDocument case skipParsing @@ -34733,6 +34995,8 @@ extension InputObjects { struct SaveFileInput: Encodable, Hashable { var clientRequestId: String + var folder: OptionalArgument = .absent() + var labels: OptionalArgument<[InputObjects.CreateLabelInput]> = .absent() var source: String @@ -34746,6 +35010,7 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(clientRequestId, forKey: .clientRequestId) + if folder.hasValue { try container.encode(folder, forKey: .folder) } if labels.hasValue { try container.encode(labels, forKey: .labels) } try container.encode(source, forKey: .source) if state.hasValue { try container.encode(state, forKey: .state) } @@ -34755,6 +35020,7 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case clientRequestId + case folder case labels case source case state @@ -34766,29 +35032,29 @@ extension InputObjects { extension InputObjects { struct SaveFilterInput: Encodable, Hashable { - var category: OptionalArgument = .absent() - var description: OptionalArgument = .absent() var filter: String + var folder: OptionalArgument = .absent() + var name: String var position: OptionalArgument = .absent() func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - if category.hasValue { try container.encode(category, forKey: .category) } if description.hasValue { try container.encode(description, forKey: .description) } try container.encode(filter, forKey: .filter) + if folder.hasValue { try container.encode(folder, forKey: .folder) } try container.encode(name, forKey: .name) if position.hasValue { try container.encode(position, forKey: .position) } } enum CodingKeys: String, CodingKey { - case category case description case filter + case folder case name case position } @@ -34799,6 +35065,8 @@ extension InputObjects { struct SavePageInput: Encodable, Hashable { var clientRequestId: String + var folder: OptionalArgument = .absent() + var labels: OptionalArgument<[InputObjects.CreateLabelInput]> = .absent() var originalContent: String @@ -34822,6 +35090,7 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(clientRequestId, forKey: .clientRequestId) + if folder.hasValue { try container.encode(folder, forKey: .folder) } if labels.hasValue { try container.encode(labels, forKey: .labels) } try container.encode(originalContent, forKey: .originalContent) if parseResult.hasValue { try container.encode(parseResult, forKey: .parseResult) } @@ -34836,6 +35105,7 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case clientRequestId + case folder case labels case originalContent case parseResult @@ -34854,6 +35124,8 @@ extension InputObjects { struct SaveUrlInput: Encodable, Hashable { var clientRequestId: String + var folder: OptionalArgument = .absent() + var labels: OptionalArgument<[InputObjects.CreateLabelInput]> = .absent() var locale: OptionalArgument = .absent() @@ -34873,6 +35145,7 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(clientRequestId, forKey: .clientRequestId) + if folder.hasValue { try container.encode(folder, forKey: .folder) } if labels.hasValue { try container.encode(labels, forKey: .labels) } if locale.hasValue { try container.encode(locale, forKey: .locale) } if publishedAt.hasValue { try container.encode(publishedAt, forKey: .publishedAt) } @@ -34885,6 +35158,7 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case clientRequestId + case folder case labels case locale case publishedAt @@ -34897,6 +35171,29 @@ extension InputObjects { } } +extension InputObjects { + struct ScanFeedsInput: Encodable, Hashable { + var opml: OptionalArgument = .absent() + + var type: Enums.ScanFeedsType + + var url: OptionalArgument = .absent() + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if opml.hasValue { try container.encode(opml, forKey: .opml) } + try container.encode(type, forKey: .type) + if url.hasValue { try container.encode(url, forKey: .url) } + } + + enum CodingKeys: String, CodingKey { + case opml + case type + case url + } + } +} + extension InputObjects { struct SetBookmarkArticleInput: Encodable, Hashable { var articleId: String @@ -34966,6 +35263,8 @@ extension InputObjects { var syncedAt: OptionalArgument = .absent() + var taskName: OptionalArgument = .absent() + var token: String var type: OptionalArgument = .absent() @@ -34977,6 +35276,7 @@ extension InputObjects { if importItemState.hasValue { try container.encode(importItemState, forKey: .importItemState) } try container.encode(name, forKey: .name) if syncedAt.hasValue { try container.encode(syncedAt, forKey: .syncedAt) } + if taskName.hasValue { try container.encode(taskName, forKey: .taskName) } try container.encode(token, forKey: .token) if type.hasValue { try container.encode(type, forKey: .type) } } @@ -34987,6 +35287,7 @@ extension InputObjects { case importItemState case name case syncedAt + case taskName case token case type } @@ -35277,12 +35578,12 @@ extension InputObjects { extension InputObjects { struct UpdateFilterInput: Encodable, Hashable { - var category: OptionalArgument = .absent() - var description: OptionalArgument = .absent() var filter: OptionalArgument = .absent() + var folder: OptionalArgument = .absent() + var id: String var name: OptionalArgument = .absent() @@ -35293,9 +35594,9 @@ extension InputObjects { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - if category.hasValue { try container.encode(category, forKey: .category) } if description.hasValue { try container.encode(description, forKey: .description) } if filter.hasValue { try container.encode(filter, forKey: .filter) } + if folder.hasValue { try container.encode(folder, forKey: .folder) } try container.encode(id, forKey: .id) if name.hasValue { try container.encode(name, forKey: .name) } if position.hasValue { try container.encode(position, forKey: .position) } @@ -35303,9 +35604,9 @@ extension InputObjects { } enum CodingKeys: String, CodingKey { - case category case description case filter + case folder case id case name case position diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift index da0b723f6..4017d7276 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FilterSelection.swift @@ -6,6 +6,7 @@ let filterSelection = Selection.Filter { InternalFilter( id: try $0.id(), name: try $0.name(), + folder: try $0.folder(), filter: try $0.filter(), visible: try $0.visible() ?? true, position: try $0.position(), diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift index ee51fd0fb..e103e690f 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalFilter.swift @@ -5,6 +5,7 @@ import Models public struct InternalFilter: Encodable, Identifiable, Hashable { public let id: String public let name: String + public let folder: String public let filter: String public let visible: Bool public let position: Int @@ -14,6 +15,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "downloaded", name: "Downloaded", + folder: "inbox", filter: "", visible: true, position: -1, @@ -25,6 +27,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "deleted", name: "Deleted", + folder: "inbox", filter: "in:trash", visible: true, position: -1, @@ -32,11 +35,12 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { ) } - public static var DefaultFilters: [InternalFilter] { + public static var DefaultInboxFilters: [InternalFilter] { [ InternalFilter( id: "inbox", name: "Inbox", + folder: "inbox", filter: "", visible: true, position: 0, @@ -45,6 +49,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "non-feed-items", name: "Non-Feed Items", + folder: "inbox", filter: "", visible: true, position: 1, @@ -53,6 +58,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "newsletters", name: "Newsletters", + folder: "inbox", filter: "", visible: true, position: 2, @@ -61,6 +67,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "feeds", name: "Feeds", + folder: "inbox", filter: "", visible: true, position: 3, @@ -69,6 +76,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "archived", name: "Archived", + folder: "inbox", filter: "is:archived", visible: true, position: 4, @@ -77,6 +85,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "files", name: "Files", + folder: "inbox", filter: "type:file", visible: true, position: 5, @@ -85,6 +94,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "highlighted", name: "Highlights", + folder: "inbox", filter: "has:highlights", visible: true, position: 6, @@ -93,6 +103,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { InternalFilter( id: "all", name: "All", + folder: "inbox", filter: "in:all", visible: true, position: 7, @@ -101,6 +112,29 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { ] } + public static var DefaultFollowingFilters: [InternalFilter] { + [ + InternalFilter( + id: "following", + name: "Following", + folder: "following", + filter: "in:following", + visible: true, + position: 0, + defaultFilter: true + ), + InternalFilter( + id: "rss", + name: "RSS", + folder: "following", + filter: "in:following label:RSS", + visible: true, + position: 1, + defaultFilter: true + ) + ] + } + public var shouldRemoteSearch: Bool { id != "downloaded" } @@ -182,7 +216,9 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate]) case "Deleted": let deletedPredicate = NSPredicate( - format: "%K == %i", #keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue) + format: "%K == %i", + #keyPath(Models.LibraryItem.serverSyncStatus), + Int64(ServerSyncStatus.needsDeletion.rawValue) ) return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate]) case "Files": @@ -227,6 +263,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { let newFilter = existing ?? Filter(entity: Filter.entity(), insertInto: context) newFilter.id = id newFilter.name = name + newFilter.folder = folder newFilter.filter = filter newFilter.visible = visible newFilter.position = Int64(position) @@ -238,11 +275,13 @@ public struct InternalFilter: Encodable, Identifiable, Hashable { filters.compactMap { filter in if let id = filter.id, let name = filter.name, + let folder = filter.folder, let filterStr = filter.filter { return InternalFilter( id: id, name: name, + folder: folder, filter: filterStr, visible: filter.visible, position: Int(filter.position), @@ -264,7 +303,6 @@ public extension Filter { ) var filter: Filter? - context.performAndWait { filter = (try? context.fetch(fetchRequest))?.first }