diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 9acaa9390..1f965db92 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -12,10 +12,8 @@ struct FeedCardNavigationLink: View { var body: some View { let destination = LinkItemDetailView( - viewModel: LinkItemDetailViewModel( - linkedItemObjectID: item.objectID, - dataService: dataService - ) + linkedItemObjectID: item.objectID, + isPDF: item.isPDF ) #if os(iOS) let modifiedDestination = destination @@ -66,10 +64,8 @@ struct GridCardNavigationLink: View { var body: some View { let destination = LinkItemDetailView( - viewModel: LinkItemDetailViewModel( - linkedItemObjectID: item.objectID, - dataService: dataService - ) + linkedItemObjectID: item.objectID, + isPDF: item.isPDF ) #if os(iOS) let modifiedDestination = destination diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 837b41376..c4a6809cd 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -6,6 +6,8 @@ import Utils import Views @MainActor final class HomeFeedViewModel: NSObject, ObservableObject { + let dateFormatter = DateFormatter.formatterISO8601 + var currentDetailViewModel: LinkItemDetailViewModel? private var fetchedResultsController: NSFetchedResultsController? @@ -23,10 +25,13 @@ import Views @Published var selectedLinkItem: NSManagedObjectID? @Published var linkRequest: LinkRequest? @Published var showLoadingBar = false - @Published var appliedFilter = LinkedItemFilter.inbox.rawValue @Published var appliedSort = LinkedItemSort.newest.rawValue - @AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) + @AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue + + @AppStorage(UserDefaultKey.lastItemSyncTime.rawValue) var lastItemSyncTime = DateFormatter.formatterISO8601.string( + from: Date(timeIntervalSinceReferenceDate: 0) + ) var cursor: String? @@ -51,6 +56,7 @@ import Views } func loadItems(dataService: DataService, isRefresh: Bool) async { + let syncStartTime = Date() let thisSearchIdx = searchIdx searchIdx += 1 @@ -62,6 +68,21 @@ import Views Task { _ = try? await dataService.fetchViewer() } } + // Fetch labels if none are available locally + let fetchRequest: NSFetchRequest = LinkedItemLabel.fetchRequest() + fetchRequest.fetchLimit = 1 + + if (try? dataService.viewContext.count(for: fetchRequest)) == 0 { + _ = try? await dataService.labels() + } + + // Sync items if necessary + let lastSyncDate = dateFormatter.date(from: lastItemSyncTime) ?? Date(timeIntervalSinceReferenceDate: 0) + let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, cursor: nil) + if syncResult != nil { + lastItemSyncTime = dateFormatter.string(from: syncStartTime) + } + let queryResult = try? await dataService.loadLinkedItems( limit: 10, searchQuery: searchQuery, diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index 3dc74d69d..3ee32d76c 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -43,8 +43,24 @@ import Views try? fetchRequest.execute() } - labels = fetchedLabels ?? [] - unselectedLabels = fetchedLabels ?? [] + if fetchedLabels?.count == 0 { + await fetchLabelsFromNetwork(dataService: dataService) + } else { + labels = fetchedLabels ?? [] + unselectedLabels = fetchedLabels ?? [] + } + } + + func fetchLabelsFromNetwork(dataService: DataService) async { + let labelIDs = try? await dataService.labels() + guard let labelIDs = labelIDs else { return } + + let fetchedLabels = await dataService.viewContext.perform { + labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel } + } + + labels = fetchedLabels + unselectedLabels = fetchedLabels } func createLabel(dataService: DataService, name: String, color: Color, description: String?) { diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index 029bf1c4a..79f0ae710 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -6,17 +6,20 @@ import Utils import Views @MainActor final class LinkItemDetailViewModel: ObservableObject { - let pdfItem: PDFItem? - let item: LinkedItem? + @Published var pdfItem: PDFItem? + @Published var item: LinkedItem? - init(linkedItemObjectID: NSManagedObjectID, dataService: DataService) { - if let linkedItem = dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem { - self.pdfItem = PDFItem.make(item: linkedItem) - self.item = linkedItem - } else { - self.pdfItem = nil - self.item = nil + func loadItem(linkedItemObjectID: NSManagedObjectID, dataService: DataService) async { + let item = await dataService.viewContext.perform { + dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem } + + if let item = item { + pdfItem = PDFItem.make(item: item) + self.item = item + } + + trackReadEvent() } func handleArchiveAction(dataService: DataService) { @@ -41,7 +44,7 @@ import Views ) } - func trackReadEvent() { + private func trackReadEvent() { guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return } guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return } guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.originalArticleURL else { return } @@ -70,14 +73,18 @@ struct LinkItemDetailView: View { @Environment(\.presentationMode) var presentationMode: Binding static let navBarHeight = 50.0 - @ObservedObject private var viewModel: LinkItemDetailViewModel + let linkedItemObjectID: NSManagedObjectID + let isPDF: Bool + + @StateObject private var viewModel = LinkItemDetailViewModel() @State private var showFontSizePopover = false @State private var showTitleEdit = false @State private var navBarVisibilityRatio = 1.0 @State private var showDeleteConfirmation = false - init(viewModel: LinkItemDetailViewModel) { - self.viewModel = viewModel + init(linkedItemObjectID: NSManagedObjectID, isPDF: Bool) { + self.linkedItemObjectID = linkedItemObjectID + self.isPDF = isPDF } var toggleReadStatusToolbarItem: some View { @@ -100,31 +107,20 @@ struct LinkItemDetailView: View { ) } - // We always want this hidden but setting it to false initially - // fixes a bug where SwiftUI searchable will always show the nav bar - // if the search field is active when pushing. - @State var hideNavBar = false - var body: some View { - if viewModel.pdfItem != nil { - fixedNavBarReader - #if os(iOS) - .navigationBarHidden(hideNavBar) - #endif - .task { - hideNavBar = true - viewModel.trackReadEvent() - } - } else if let item = viewModel.item { - WebReaderContainerView(item: item) - #if os(iOS) - .navigationBarHidden(hideNavBar) - #endif - .task { - hideNavBar = true - viewModel.trackReadEvent() + ZStack { // Using ZStack so .task can be used on if/else body + if isPDF { + pdfContainerView + } else if let item = viewModel.item { + WebReaderContainerView(item: item) } } + .task { + await viewModel.loadItem(linkedItemObjectID: linkedItemObjectID, dataService: dataService) + } + #if os(iOS) + .navigationBarHidden(true) + #endif } var navBar: some View { @@ -197,7 +193,7 @@ struct LinkItemDetailView: View { } } - @ViewBuilder private var fixedNavBarReader: some View { + @ViewBuilder private var pdfContainerView: some View { if let pdfItem = viewModel.pdfItem, let pdfURL = pdfItem.pdfURL { #if os(iOS) PDFViewer(viewModel: PDFViewerViewModel(pdfItem: pdfItem)) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ManageAccountView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ManageAccountView.swift index 940c5059c..e3db2601e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ManageAccountView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ManageAccountView.swift @@ -37,6 +37,12 @@ struct ManageAccountView: View { }, label: { Text("Delete Account") } ) + Button( + action: { + dataService.resetCoreData() + }, + label: { Text("Reset Data Cache") } + ) .alert(isPresented: $showDeleteAccountConfirmation) { Alert( title: Text("Are you sure you want to delete your account? This action can't be undone."), diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index bcdff4116..0d9aadb5f 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -154,4 +154,12 @@ public extension LinkedItem { } } } + + static func deleteItems(ids: [String], context: NSManagedObjectContext) { + for itemID in ids { + if let linkedItem = lookup(byID: itemID, inContext: context) { + linkedItem.remove(inContext: context) + } + } + } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 64d58fb33..c05cb576c 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -95,7 +95,12 @@ public final class DataService: ObservableObject { } } - func resetCoreData() { + public func resetCoreData() { + UserDefaults.standard.set( + DateFormatter.formatterISO8601.string(from: Date(timeIntervalSinceReferenceDate: 0)), + forKey: UserDefaultKey.lastItemSyncTime.rawValue + ) + clearCoreData() persistentContainer = PersistentContainer.make() diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 5cde5af97..f34630594 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -9729,6 +9729,7 @@ extension Objects { let sharedArticle: [String: Unions.SharedArticleResult] let subscriptions: [String: Unions.SubscriptionsResult] let typeaheadSearch: [String: Unions.TypeaheadSearchResult] + let updatesSince: [String: Unions.UpdatesSinceResult] let user: [String: Unions.UserResult] let users: [String: Unions.UsersResult] let validateUsername: [String: Bool] @@ -9825,6 +9826,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.TypeaheadSearchResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "updatesSince": + if let value = try container.decode(Unions.UpdatesSinceResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "user": if let value = try container.decode(Unions.UserResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -9873,6 +9878,7 @@ extension Objects.Query: Decodable { sharedArticle = map["sharedArticle"] subscriptions = map["subscriptions"] typeaheadSearch = map["typeaheadSearch"] + updatesSince = map["updatesSince"] user = map["user"] users = map["users"] validateUsername = map["validateUsername"] @@ -10217,6 +10223,25 @@ extension Fields where TypeLock == Objects.Query { } } + func updatesSince(after: OptionalArgument = .absent(), first: OptionalArgument = .absent(), since: DateTime, 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)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updatesSince[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func user(userId: OptionalArgument = .absent(), username: OptionalArgument = .absent(), selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "user", @@ -14366,6 +14391,141 @@ extension Selection where TypeLock == Never, Type == Never { typealias SubscriptionsSuccess = Selection } +extension Objects { + struct SyncUpdatedItemEdge { + let __typename: TypeName = .syncUpdatedItemEdge + let cursor: [String: String] + let itemId: [String: String] + let node: [String: Objects.SearchItem] + let updateReason: [String: Enums.UpdateReason] + + enum TypeName: String, Codable { + case syncUpdatedItemEdge = "SyncUpdatedItemEdge" + } + } +} + +extension Objects.SyncUpdatedItemEdge: 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 "cursor": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "itemId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "node": + if let value = try container.decode(Objects.SearchItem?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "updateReason": + if let value = try container.decode(Enums.UpdateReason?.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)." + ) + ) + } + } + + cursor = map["cursor"] + itemId = map["itemId"] + node = map["node"] + updateReason = map["updateReason"] + } +} + +extension Fields where TypeLock == Objects.SyncUpdatedItemEdge { + func cursor() throws -> String { + let field = GraphQLField.leaf( + name: "cursor", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.cursor[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func itemId() throws -> String { + let field = GraphQLField.leaf( + name: "itemID", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.itemId[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func node(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "node", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.node[field.alias!]) + case .mocking: + return selection.mock() + } + } + + func updateReason() throws -> Enums.UpdateReason { + let field = GraphQLField.leaf( + name: "updateReason", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updateReason[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.UpdateReason.allCases.first! + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SyncUpdatedItemEdge = Selection +} + extension Objects { struct TypeaheadSearchError { let __typename: TypeName = .typeaheadSearchError @@ -15963,6 +16123,162 @@ extension Selection where TypeLock == Never, Type == Never { typealias UpdateUserSuccess = Selection } +extension Objects { + struct UpdatesSinceError { + let __typename: TypeName = .updatesSinceError + let errorCodes: [String: [Enums.UpdatesSinceErrorCode]] + + enum TypeName: String, Codable { + case updatesSinceError = "UpdatesSinceError" + } + } +} + +extension Objects.UpdatesSinceError: 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.UpdatesSinceErrorCode]?.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.UpdatesSinceError { + func errorCodes() throws -> [Enums.UpdatesSinceErrorCode] { + 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 UpdatesSinceError = Selection +} + +extension Objects { + struct UpdatesSinceSuccess { + let __typename: TypeName = .updatesSinceSuccess + let edges: [String: [Objects.SyncUpdatedItemEdge]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case updatesSinceSuccess = "UpdatesSinceSuccess" + } + } +} + +extension Objects.UpdatesSinceSuccess: 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 "edges": + if let value = try container.decode([Objects.SyncUpdatedItemEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.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)." + ) + ) + } + } + + edges = map["edges"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Objects.UpdatesSinceSuccess { + func edges(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "edges", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.edges[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func pageInfo(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "pageInfo", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageInfo[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdatesSinceSuccess = Selection +} + extension Objects { struct UploadFileRequestError { let __typename: TypeName = .uploadFileRequestError @@ -22023,6 +22339,86 @@ extension Selection where TypeLock == Never, Type == Never { typealias UpdateUserResult = Selection } +extension Unions { + struct UpdatesSinceResult { + let __typename: TypeName + let edges: [String: [Objects.SyncUpdatedItemEdge]] + let errorCodes: [String: [Enums.UpdatesSinceErrorCode]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case updatesSinceError = "UpdatesSinceError" + case updatesSinceSuccess = "UpdatesSinceSuccess" + } + } +} + +extension Unions.UpdatesSinceResult: 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 "edges": + if let value = try container.decode([Objects.SyncUpdatedItemEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.UpdatesSinceErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.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")!) + + edges = map["edges"] + errorCodes = map["errorCodes"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Unions.UpdatesSinceResult { + func on(updatesSinceError: Selection, updatesSinceSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "UpdatesSinceError", selection: updatesSinceError.selection), GraphQLField.fragment(type: "UpdatesSinceSuccess", selection: updatesSinceSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .updatesSinceError: + let data = Objects.UpdatesSinceError(errorCodes: data.errorCodes) + return try updatesSinceError.decode(data: data) + case .updatesSinceSuccess: + let data = Objects.UpdatesSinceSuccess(edges: data.edges, pageInfo: data.pageInfo) + return try updatesSinceSuccess.decode(data: data) + } + case .mocking: + return updatesSinceError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdatesSinceResult = Selection +} + extension Unions { struct UploadFileRequestResult { let __typename: TypeName @@ -22466,6 +22862,8 @@ extension Enums { extension Enums { /// ArticleSavingRequestStatus enum ArticleSavingRequestStatus: String, CaseIterable, Codable { + case deleted = "DELETED" + case failed = "FAILED" case processing = "PROCESSING" @@ -23137,6 +23535,17 @@ extension Enums { } } +extension Enums { + /// UpdateReason + enum UpdateReason: String, CaseIterable, Codable { + case created = "CREATED" + + case deleted = "DELETED" + + case updated = "UPDATED" + } +} + extension Enums { /// UpdateReminderErrorCode enum UpdateReminderErrorCode: String, CaseIterable, Codable { @@ -23185,6 +23594,13 @@ extension Enums { } } +extension Enums { + /// UpdatesSinceErrorCode + enum UpdatesSinceErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// UploadFileRequestErrorCode enum UploadFileRequestErrorCode: String, CaseIterable, Codable { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift index 24c6a5738..66dc75968 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Public/LinkedItemLoading.swift @@ -3,6 +3,49 @@ import Foundation import Models public extension DataService { + /// Requests `LinkedItem`s updates from the server since a certain datae + /// and stores it in CoreData while deleting all the items with ids the server says + /// have been deleted. + /// - Parameters: + /// - limit: max count of items + /// - searchQuery: search terms and filters + /// - cursor: cursor when loading batch for infinite list + /// - Returns: `LinkedItemQueryResult` (managed object IDs and an optional cursor) + func syncLinkedItems( + since date: Date, + cursor: String?, + previousQueryResult: LinkedItemQueryResult? = nil + ) async throws -> LinkedItemQueryResult { + if previousQueryResult == nil { + // Send offline changes to server before fetching items + // only on the first call of this function + try? await syncOfflineItemsWithServerIfNeeded() + } + + let fetchResult = try await linkedItemUpdates(since: date, limit: 20, cursor: cursor) + + LinkedItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext) + + guard let itemIDs = fetchResult.items.persist(context: backgroundContext) else { + throw BasicError.message(messageText: "CoreData error") + } + + let result = LinkedItemQueryResult( + itemIDs: itemIDs + (previousQueryResult?.itemIDs ?? []), + cursor: fetchResult.cursor + ) + + if fetchResult.hasMoreItems, (previousQueryResult?.itemIDs.count ?? 0) < 200 { + return try await syncLinkedItems( + since: date, + cursor: fetchResult.cursor, + previousQueryResult: result + ) + } + + return result + } + /// Requests `LinkedItem`s from the server and stores it in CoreData. /// - Parameters: /// - limit: max count of items diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift index 19fa42483..348e26cd9 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LinkedItemNetworkQuery.swift @@ -8,7 +8,104 @@ struct InternalLinkedItemQueryResult { let cursor: String? } +struct InternalLinkedItemUpdatesQueryResult { + let items: [InternalLinkedItem] + let deletedItemIDs: [String] + let cursor: String? + let hasMoreItems: Bool +} + +private struct SyncItemEdge { + let itemID: String + let isDeletedItem: Bool + let item: InternalLinkedItem? +} + extension DataService { + // swiftlint:disable:next function_body_length + func linkedItemUpdates( + since: Date, + limit: Int, + cursor: String? + ) async throws -> InternalLinkedItemUpdatesQueryResult { + struct QuerySuccessResult { + let edges: [SyncItemEdge] + let cursor: String? + let hasMoreItems: Bool + } + enum QueryResult { + case success(result: QuerySuccessResult) + case error(error: String) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + let selection = Selection { + try $0.on( + updatesSinceError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }, + updatesSinceSuccess: .init { + QueryResult.success( + result: QuerySuccessResult( + edges: try $0.edges(selection: syncItemEdgeSelection.list), + cursor: try $0.pageInfo(selection: Selection.PageInfo { + try $0.endCursor() + }), + hasMoreItems: try $0.pageInfo(selection: Selection.PageInfo { + try $0.hasNextPage() + }) + ) + ) + } + ) + } + + let query = Selection.Query { + try $0.updatesSince( + after: OptionalArgument(cursor), + first: OptionalArgument(limit), + since: DateTime(from: since), + selection: selection + ) + } + + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: ContentFetchError.network) + return + } + + switch payload.data { + case let .success(result: result): + var items = [InternalLinkedItem]() + var deletedItemIDs = [String]() + + for edge in result.edges { + if edge.isDeletedItem { + deletedItemIDs.append(edge.itemID) + } else if let item = edge.item { + items.append(item) + } + } + + continuation.resume( + returning: InternalLinkedItemUpdatesQueryResult( + items: items, + deletedItemIDs: deletedItemIDs, + cursor: result.cursor, + hasMoreItems: result.hasMoreItems + ) + ) + case let .error(error): + continuation.resume(throwing: ContentFetchError.unknown(description: error.description)) + } + } + } + } + /// Performs GraphQL request to fetch `InternalLinkedItem`s and a cursor value /// - Parameters: /// - limit: max number of items to return @@ -152,6 +249,14 @@ private let libraryArticleSelection = Selection.Article { ) } +private let syncItemEdgeSelection = Selection.SyncUpdatedItemEdge { + SyncItemEdge( + itemID: try $0.itemId(), + isDeletedItem: try $0.updateReason() == .deleted, + item: try $0.node(selection: searchItemSelection.nullable) + ) +} + private let searchItemSelection = Selection.SearchItem { InternalLinkedItem( id: try $0.id(), diff --git a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift index a7fe46e3b..f39daf4b6 100644 --- a/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift +++ b/apple/OmnivoreKit/Sources/Services/InternalModels/InternalLinkedItem.swift @@ -61,6 +61,11 @@ struct InternalLinkedItem { linkedItem.contentReader = contentReader linkedItem.originalHtml = originalHtml + // Remove existing labels in case a label had been deleted + if let existingLabels = linkedItem.labels { + linkedItem.removeFromLabels(existingLabels) + } + for label in labels { linkedItem.addToLabels(label.asManagedObject(inContext: context)) } diff --git a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift index 29ed93b7b..deab520db 100644 --- a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift +++ b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift @@ -12,4 +12,5 @@ public enum UserDefaultKey: String { case lastSelectedLinkedItemFilter case lastUsedAppVersion case lastUsedAppBuildNumber + case lastItemSyncTime }