mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1045 from omnivore-app/feature/use-sync-api-ios
Sync API (iOS)
This commit is contained in:
commit
80c77c28a4
12 changed files with 667 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,5 @@ public enum UserDefaultKey: String {
|
|||
case lastSelectedLinkedItemFilter
|
||||
case lastUsedAppVersion
|
||||
case lastUsedAppBuildNumber
|
||||
case lastItemSyncTime
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue