Merge pull request #1045 from omnivore-app/feature/use-sync-api-ios

Sync API (iOS)
This commit is contained in:
Satindar Dhillon 2022-08-08 10:33:15 -07:00 committed by GitHub
commit 80c77c28a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 667 additions and 49 deletions

View file

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

View file

@ -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<LinkedItem>?
@ -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<Models.LinkedItemLabel> = 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,

View file

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

View file

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

View file

@ -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."),

View file

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

View file

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

View file

@ -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<Type>(after: OptionalArgument<String> = .absent(), first: OptionalArgument<Int> = .absent(), since: DateTime, selection: Selection<Type, Unions.UpdatesSinceResult>) 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<Type>(userId: OptionalArgument<String> = .absent(), username: OptionalArgument<String> = .absent(), selection: Selection<Type, Unions.UserResult>) throws -> Type {
let field = GraphQLField.composite(
name: "user",
@ -14366,6 +14391,141 @@ extension Selection where TypeLock == Never, Type == Never {
typealias SubscriptionsSuccess<T> = Selection<T, Objects.SubscriptionsSuccess>
}
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<Type>(selection: Selection<Type, Objects.SearchItem?>) 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<T> = Selection<T, Objects.SyncUpdatedItemEdge>
}
extension Objects {
struct TypeaheadSearchError {
let __typename: TypeName = .typeaheadSearchError
@ -15963,6 +16123,162 @@ extension Selection where TypeLock == Never, Type == Never {
typealias UpdateUserSuccess<T> = Selection<T, Objects.UpdateUserSuccess>
}
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<T> = Selection<T, Objects.UpdatesSinceError>
}
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<Type>(selection: Selection<Type, [Objects.SyncUpdatedItemEdge]>) 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<Type>(selection: Selection<Type, Objects.PageInfo>) 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<T> = Selection<T, Objects.UpdatesSinceSuccess>
}
extension Objects {
struct UploadFileRequestError {
let __typename: TypeName = .uploadFileRequestError
@ -22023,6 +22339,86 @@ extension Selection where TypeLock == Never, Type == Never {
typealias UpdateUserResult<T> = Selection<T, Unions.UpdateUserResult>
}
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<Type>(updatesSinceError: Selection<Type, Objects.UpdatesSinceError>, updatesSinceSuccess: Selection<Type, Objects.UpdatesSinceSuccess>) 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<T> = Selection<T, Unions.UpdatesSinceResult>
}
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 {

View file

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

View file

@ -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<QueryResult, Unions.UpdatesSinceResult> {
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(),

View file

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

View file

@ -12,4 +12,5 @@ public enum UserDefaultKey: String {
case lastSelectedLinkedItemFilter
case lastUsedAppVersion
case lastUsedAppBuildNumber
case lastItemSyncTime
}