Merge pull request #502 from omnivore-app/feature/labels-syncing

Labels syncing
This commit is contained in:
Satindar Dhillon
2022-04-27 21:12:32 -07:00
committed by GitHub
18 changed files with 333 additions and 416 deletions

View File

@ -48,7 +48,7 @@ import Views
loadItems(isRefresh: true)
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item)) { _ in }
ApplyLabelsView(mode: .item(item))
}
}
.navigationTitle("Home")
@ -67,13 +67,18 @@ import Views
viewModel.selectedLinkItem = linkedItem
}
.formSheet(isPresented: $viewModel.snoozePresented) {
SnoozeView(snoozePresented: $viewModel.snoozePresented, itemToSnoozeID: $viewModel.itemToSnoozeID) {
viewModel.snoozeUntil(
dataService: dataService,
linkId: $0.feedItemId,
until: $0.snoozeUntilDate,
successMessage: $0.successMessage
)
SnoozeView(
snoozePresented: $viewModel.snoozePresented,
itemToSnoozeID: $viewModel.itemToSnoozeID
) { snoozeParams in
Task {
await viewModel.snoozeUntil(
dataService: dataService,
linkId: snoozeParams.feedItemId,
until: snoozeParams.snoozeUntilDate,
successMessage: snoozeParams.successMessage
)
}
}
}
.onAppear { // TODO: use task instead
@ -106,9 +111,7 @@ import Views
}
.padding(.horizontal)
.sheet(isPresented: $showLabelsSheet) {
ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in
viewModel.selectedLabels = labels
}
ApplyLabelsView(mode: .list(viewModel.selectedLabels))
}
}
if prefersListLayout {

View File

@ -1,4 +1,3 @@
import Combine
import CoreData
import Models
import Services
@ -26,8 +25,6 @@ import Views
var searchIdx = 0
var receivedIdx = 0
var subscriptions = Set<AnyCancellable>()
init() {}
func itemAppeared(item: LinkedItem, dataService: DataService) async {
@ -83,7 +80,7 @@ import Views
isLoading = false
receivedIdx = thisSearchIdx
cursor = queryResult.cursor
dataService.prefetchPages(itemSlugs: newItems.map(\.unwrappedSlug))
await dataService.prefetchPages(itemSlugs: newItems.map(\.unwrappedSlug))
} else if searchTermIsEmpty {
await dataService.viewContext.perform {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
@ -158,31 +155,27 @@ import Views
dataService.removeLink(objectID: objectID)
}
func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) {
func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) async {
isLoading = true
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
items.remove(at: itemIndex)
}
dataService.createReminderPublisher(
reminderItemId: .link(id: linkId),
remindAt: until
)
.sink(
receiveCompletion: { [weak self] completion in
guard case .failure = completion else { return }
self?.isLoading = false
NSNotification.operationFailed(message: "Failed to snooze")
},
receiveValue: { [weak self] _ in
self?.isLoading = false
if let message = successMessage {
Snackbar.show(message: message)
}
do {
try await dataService.createReminder(
reminderItemId: .link(id: linkId),
remindAt: until
)
if let message = successMessage {
Snackbar.show(message: message)
}
)
.store(in: &subscriptions)
} catch {
NSNotification.operationFailed(message: "Failed to snooze")
}
isLoading = false
}
private var searchQuery: String? {

View File

@ -28,7 +28,6 @@ struct ApplyLabelsView: View {
}
let mode: Mode
let commitLabelChanges: ([LinkedItemLabel]) -> Void
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@ -100,14 +99,11 @@ struct ApplyLabelsView: View {
action: {
switch mode {
case let .item(feedItem):
viewModel.saveItemLabelChanges(itemID: feedItem.unwrappedID, dataService: dataService) { labels in
commitLabelChanges(labels)
presentationMode.wrappedValue.dismiss()
}
viewModel.saveItemLabelChanges(itemID: feedItem.unwrappedID, dataService: dataService)
case .list:
commitLabelChanges(viewModel.selectedLabels)
presentationMode.wrappedValue.dismiss()
break
}
presentationMode.wrappedValue.dismiss()
},
label: { Text(mode.confirmButtonText).foregroundColor(.appGrayTextContrast) }
)
@ -135,12 +131,12 @@ struct ApplyLabelsView: View {
#endif
}
}
.onAppear {
.task {
switch mode {
case let .item(feedItem):
viewModel.loadLabels(dataService: dataService, item: feedItem)
await viewModel.loadLabels(dataService: dataService, item: feedItem)
case let .list(labels):
viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
await viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
}
}
}

View File

@ -9,7 +9,7 @@ struct LabelsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = LabelsViewModel()
@State private var showDeleteConfirmation = false
@State private var labelToRemoveID: String?
@State private var labelToRemove: LinkedItemLabel?
let footerText = "Use labels to create curated collections of links."
@ -20,14 +20,18 @@ struct LabelsView: View {
innerBody
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
Button("Delete Label", role: .destructive) {
if let labelID = labelToRemoveID {
if let label = labelToRemove {
withAnimation {
viewModel.deleteLabel(dataService: dataService, labelID: labelID)
viewModel.deleteLabel(
dataService: dataService,
labelID: label.unwrappedID,
name: label.unwrappedName
)
}
}
self.labelToRemoveID = nil
self.labelToRemove = nil
}
Button("Cancel", role: .cancel) { self.labelToRemoveID = nil }
Button("Cancel", role: .cancel) { self.labelToRemove = nil }
}
}
#elseif os(macOS)
@ -37,7 +41,7 @@ struct LabelsView: View {
.listStyle(InsetListStyle())
#endif
}
.onAppear { viewModel.loadLabels(dataService: dataService, item: nil) }
.task { await viewModel.loadLabels(dataService: dataService, item: nil) }
}
private var innerBody: some View {
@ -64,7 +68,7 @@ struct LabelsView: View {
Spacer()
Button(
action: {
labelToRemoveID = label.id
labelToRemove = label
showDeleteConfirmation = true
},
label: { Image(systemName: "trash") }

View File

@ -1,11 +1,9 @@
import Combine
import Models
import Services
import SwiftUI
import Views
final class LabelsViewModel: ObservableObject {
private var hasLoadedInitialLabels = false
@MainActor final class LabelsViewModel: ObservableObject {
@Published var isLoading = false
@Published var selectedLabels = [LinkedItemLabel]()
@Published var unselectedLabels = [LinkedItemLabel]()
@ -13,102 +11,67 @@ final class LabelsViewModel: ObservableObject {
@Published var showCreateEmailModal = false
@Published var labelSearchFilter = ""
var subscriptions = Set<AnyCancellable>()
func loadLabels(
dataService: DataService,
item: LinkedItem? = nil,
initiallySelectedLabels: [LinkedItemLabel]? = nil
) {
guard !hasLoadedInitialLabels else { return }
) async {
isLoading = true
dataService.labelsPublisher().sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] labelIDs in
guard let self = self else { return }
dataService.viewContext.performAndWait {
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
}
let selLabels = initiallySelectedLabels ?? item?.labels.asArray(of: LinkedItemLabel.self) ?? []
for label in self.labels {
if selLabels.contains(label) {
self.selectedLabels.append(label)
} else {
self.unselectedLabels.append(label)
}
}
self.hasLoadedInitialLabels = true
self.isLoading = false
if let labelIDs = try? await dataService.labels() {
dataService.viewContext.performAndWait {
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
}
)
.store(in: &subscriptions)
let selLabels = initiallySelectedLabels ?? item?.labels.asArray(of: LinkedItemLabel.self) ?? []
for label in labels {
if selLabels.contains(label) {
selectedLabels.append(label)
} else {
unselectedLabels.append(label)
}
}
}
isLoading = false
}
func createLabel(dataService: DataService, name: String, color: Color, description: String?) {
isLoading = true
dataService.createLabelPublisher(
guard let labelObjectID = try? dataService.createLabel(
name: name,
color: color.hex ?? "",
description: description
).sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
},
receiveValue: { [weak self] labelID in
if let label = dataService.viewContext.object(with: labelID) as? LinkedItemLabel {
self?.labels.insert(label, at: 0)
self?.unselectedLabels.insert(label, at: 0)
}
self?.isLoading = false
self?.showCreateEmailModal = false
}
)
.store(in: &subscriptions)
) else {
return
}
if let label = dataService.viewContext.object(with: labelObjectID) as? LinkedItemLabel {
labels.insert(label, at: 0)
unselectedLabels.insert(label, at: 0)
}
showCreateEmailModal = false
}
func deleteLabel(dataService: DataService, labelID: String) {
isLoading = true
dataService.removeLabelPublisher(labelID: labelID).sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
},
receiveValue: { [weak self] _ in
self?.isLoading = false
self?.labels.removeAll { $0.id == labelID }
}
)
.store(in: &subscriptions)
func deleteLabel(dataService: DataService, labelID: String, name: String) {
dataService.removeLabel(labelID: labelID, name: name)
labels.removeAll { $0.name == name }
selectedLabels.removeAll { $0.name == name }
unselectedLabels.removeAll { $0.name == name }
}
func saveItemLabelChanges(
itemID: String,
dataService: DataService,
onComplete: @escaping ([LinkedItemLabel]) -> Void
) {
isLoading = true
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.unwrappedID)).sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
},
receiveValue: { labelIDs in
onComplete(
labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
)
}
)
.store(in: &subscriptions)
func saveItemLabelChanges(itemID: String, dataService: DataService) {
dataService.updateItemLabels(itemID: itemID, labelNames: selectedLabels.map(\.unwrappedName))
}
func addLabelToItem(_ label: LinkedItemLabel) {
selectedLabels.insert(label, at: 0)
unselectedLabels.removeAll { $0.id == label.id }
unselectedLabels.removeAll { $0.name == label.name }
}
func removeLabelFromItem(_ label: LinkedItemLabel) {
unselectedLabels.insert(label, at: 0)
selectedLabels.removeAll { $0.id == label.id }
selectedLabels.removeAll { $0.name == label.name }
}
}

View File

@ -76,21 +76,11 @@ enum PDFProvider {
queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"]
)
let newWebAppWrapperViewModel = WebAppWrapperViewModel(
webAppWrapperViewModel = WebAppWrapperViewModel(
webViewURLRequest: urlRequest,
baseURL: baseURL,
rawAuthCookie: rawAuthCookie
)
newWebAppWrapperViewModel.performActionSubject.sink { action in
switch action {
case let .shareHighlight(highlightID):
print("show share modal for highlight with id: \(highlightID)")
}
}
.store(in: &newWebAppWrapperViewModel.subscriptions)
webAppWrapperViewModel = newWebAppWrapperViewModel
}
}

View File

@ -190,10 +190,8 @@ import WebKit
} else {
Color.clear
.contentShape(Rectangle())
.onAppear {
if !viewModel.isLoading {
viewModel.loadContent(dataService: dataService, slug: item.unwrappedSlug)
}
.task {
await viewModel.loadContent(dataService: dataService, slug: item.unwrappedSlug)
}
}
if showFontSizePopover {

View File

@ -1,4 +1,3 @@
import Combine
import Models
import Services
import SwiftUI
@ -9,33 +8,15 @@ struct SafariWebLink: Identifiable {
let url: URL
}
final class WebReaderViewModel: ObservableObject {
@Published var isLoading = false
@MainActor final class WebReaderViewModel: ObservableObject {
@Published var articleContent: ArticleContent?
var slug: String?
var subscriptions = Set<AnyCancellable>()
func loadContent(dataService: DataService, slug: String) {
func loadContent(dataService: DataService, slug: String) async {
self.slug = slug
isLoading = true
guard let username = dataService.currentViewer?.username else { return }
if let content = dataService.pageFromCache(slug: slug) {
articleContent = content
} else {
dataService.articleContentPublisher(username: username, slug: slug).sink(
receiveCompletion: { [weak self] completion in
guard case .failure = completion else { return }
self?.isLoading = false
},
receiveValue: { [weak self] articleContent in
self?.articleContent = articleContent
}
)
.store(in: &subscriptions)
}
articleContent = try? await dataService.articleContent(username: username, slug: slug, useCache: true)
}
func createHighlight(
@ -141,16 +122,12 @@ final class WebReaderViewModel: ObservableObject {
switch actionID {
case "deleteHighlight":
dataService.invalidateCachedPage(slug: slug)
deleteHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "createHighlight":
dataService.invalidateCachedPage(slug: slug)
createHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "mergeHighlight":
dataService.invalidateCachedPage(slug: slug)
mergeHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "updateHighlight":
dataService.invalidateCachedPage(slug: slug)
updateHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "articleReadingProgress":
updateReadingProgress(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)

View File

@ -50,39 +50,3 @@ public final class DataService: ObservableObject {
}
}
}
public extension DataService {
func prefetchPages(itemSlugs: [String]) {
guard let username = currentViewer?.username else { return }
for slug in itemSlugs {
articleContentPublisher(username: username, slug: slug).sink(
receiveCompletion: { _ in },
receiveValue: { _ in }
)
.store(in: &subscriptions)
}
}
func pageFromCache(slug: String) -> ArticleContent? {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "slug == %@", slug
)
guard let linkedItem = try? persistentContainer.viewContext.fetch(linkedItemFetchRequest).first else { return nil }
guard let htmlContent = linkedItem.htmlContent else { return nil }
let highlights = linkedItem
.highlights
.asArray(of: Highlight.self)
.filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }
return ArticleContent(
htmlContent: htmlContent,
highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString
)
}
func invalidateCachedPage(slug _: String?) {}
}

View File

@ -4,12 +4,30 @@ import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func createLabelPublisher(
extension DataService {
public func createLabel(
name: String,
color: String,
description: String?
) -> AnyPublisher<NSManagedObjectID, BasicError> {
) throws -> NSManagedObjectID {
let internalLabel = InternalLinkedItemLabel(
id: UUID().uuidString,
name: name,
color: color,
createdAt: nil,
labelDescription: description
)
if let labelObjectID = internalLabel.persist(context: backgroundContext) {
// Send update to server
syncLabelCreation(label: internalLabel)
return labelObjectID
} else {
throw BasicError.message(messageText: "core data error")
}
}
func syncLabelCreation(label: InternalLinkedItemLabel) {
enum MutationResult {
case saved(label: InternalLinkedItemLabel)
case error(errorCode: Enums.CreateLabelErrorCode)
@ -25,9 +43,9 @@ public extension DataService {
let mutation = Selection.Mutation {
try $0.createLabel(
input: InputObjects.CreateLabelInput(
name: name,
color: color,
description: OptionalArgument(description)
name: label.name,
color: label.color,
description: OptionalArgument(label.labelDescription)
),
selection: selection
)
@ -35,33 +53,45 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
send(mutation, to: path, headers: headers) { result in
let payload = try? result.get()
switch payload.data {
case let .saved(label: label):
if let labelObjectID = [label].persist(context: self.backgroundContext)?.first {
promise(.success(labelObjectID))
} else {
promise(.failure(.message(messageText: "core data error")))
}
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
let updatedLabelID: String? = {
if let payload = try? result.get() {
switch payload.data {
case let .saved(label: label):
return label.id
case .error:
return nil
}
}
return nil
}()
let syncStatus: ServerSyncStatus = payload == nil ? .needsCreation : .isNSync
context.perform {
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItemLabel.name), label.name)
guard let labelObject = (try? context.fetch(fetchRequest))?.first else { return }
labelObject.serverSyncStatus = Int64(syncStatus.rawValue)
// Updated id with the one generated on the server
if let updatedLabelID = updatedLabelID {
labelObject.id = updatedLabelID
}
do {
try context.save()
logger.debug("Label created succesfully")
} catch {
context.rollback()
logger.debug("Failed to create Label: \(error.localizedDescription)")
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View File

@ -27,10 +27,10 @@ public enum ReminderItemId {
}
public extension DataService {
func createReminderPublisher(
func createReminder(
reminderItemId: ReminderItemId,
remindAt: Date
) -> AnyPublisher<String, BasicError> {
) async throws {
enum MutationResult {
case complete(id: String)
case error(errorCode: Enums.CreateReminderErrorCode)
@ -61,28 +61,20 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .complete(id: id):
promise(.success(id))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
switch payload.data {
case .complete:
continuation.resume()
case let .error(errorCode: errorCode):
continuation.resume(throwing: BasicError.message(messageText: errorCode.rawValue))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View File

@ -3,8 +3,20 @@ import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func removeLabelPublisher(labelID: String) -> AnyPublisher<Bool, BasicError> {
extension DataService {
public func removeLabel(labelID: String, name: String) {
// Update CoreData
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let label = LinkedItemLabel.lookup(byName: name, inContext: self.backgroundContext) else { return }
label.remove(inContext: self.backgroundContext)
// Send update to server
self.syncLabelDeletion(labelID: labelID, labelName: name)
}
}
func syncLabelDeletion(labelID: String, labelName: String) {
enum MutationResult {
case success(labelID: String)
case error(errorCode: Enums.DeleteLabelErrorCode)
@ -25,34 +37,30 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if payload.errors != nil {
promise(.failure(.message(messageText: "Error removing label")))
}
send(mutation, to: path, headers: headers) { result in
let data = try? result.get()
let isSyncSuccess = data != nil
switch payload.data {
case .success:
if let label = LinkedItemLabel.lookup(byID: labelID, inContext: self.backgroundContext) {
label.remove(inContext: self.backgroundContext)
promise(.success(true))
} else {
promise(.failure(.message(messageText: "Error removing label")))
}
case .error:
promise(.failure(.message(messageText: "Error removing label")))
}
case .failure:
promise(.failure(.message(messageText: "Error removing label")))
}
context.perform {
let label = LinkedItemLabel.lookup(byName: labelName, inContext: context)
guard let label = label else { return }
if isSyncSuccess {
label.remove(inContext: context)
} else {
label.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
}
do {
try context.save()
logger.debug("LinkedItem deleted succesfully")
} catch {
context.rollback()
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View File

@ -1,15 +1,33 @@
import Combine
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
// swiftlint:disable:next function_body_length
func updateArticleLabelsPublisher(
itemID: String,
labelIDs: [String]
) -> AnyPublisher<[NSManagedObjectID], BasicError> {
extension DataService {
public func updateItemLabels(itemID: String, labelNames: [String]) {
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
if let existingLabels = linkedItem.labels {
linkedItem.removeFromLabels(existingLabels)
}
var labelIDs = [String]()
for labelName in labelNames {
if let labelObject = LinkedItemLabel.lookup(byName: labelName, inContext: self.backgroundContext) {
linkedItem.addToLabels(labelObject)
labelIDs.append(labelObject.unwrappedID)
}
}
// Send update to server
self.syncLabelUpdates(itemID: itemID, labelIDs: labelIDs)
}
}
func syncLabelUpdates(itemID: String, labelIDs: [String]) {
enum MutationResult {
case saved(feedItem: [InternalLinkedItemLabel])
case error(errorCode: Enums.SetLabelsErrorCode)
@ -34,54 +52,24 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: graphqlError.first.debugDescription)))
}
send(mutation, to: path, headers: headers) { result in
let data = try? result.get()
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
switch payload.data {
case let .saved(labels):
self.backgroundContext.perform {
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else {
promise(.failure(.message(messageText: "failed to set labels")))
return
}
context.perform {
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
if let existingLabels = linkedItem.labels {
linkedItem.removeFromLabels(existingLabels)
}
for label in labels {
if let labelObject = LinkedItemLabel.lookup(byID: label.id, inContext: self.backgroundContext) {
linkedItem.addToLabels(labelObject)
}
}
do {
try self.backgroundContext.save()
logger.debug("Item labels updated")
let labelObjects = linkedItem.labels.asArray(of: LinkedItemLabel.self)
promise(.success(labelObjects.map(\.objectID)))
} catch {
self.backgroundContext.rollback()
logger.debug("Failed to update item labels: \(error.localizedDescription)")
promise(.failure(.message(messageText: "failed to set labels")))
}
}
case .error:
promise(.failure(.message(messageText: "failed to set labels")))
}
case .failure:
promise(.failure(.message(messageText: "failed to set labels")))
}
do {
try context.save()
logger.debug("Item labels updated succesfully")
} catch {
context.rollback()
logger.debug("Failed to update item labels: \(error.localizedDescription)")
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View File

@ -1,16 +1,28 @@
import Combine
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
struct ArticleProps {
let htmlContent: String
let highlights: [InternalHighlight]
extension DataService {
public func prefetchPages(itemSlugs: [String]) async {
guard let username = currentViewer?.username else { return }
for slug in itemSlugs {
// TODO: maybe check for cached content before downloading again? check timestamp?
_ = try? await articleContent(username: username, slug: slug, useCache: false)
}
}
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<ArticleContent, ServerError> {
public func articleContent(username: String, slug: String, useCache: Bool) async throws -> ArticleContent {
struct ArticleProps {
let htmlContent: String
let highlights: [InternalHighlight]
}
if useCache, let cachedContent = cachedArticleContent(slug: slug) {
return cachedContent
}
enum QueryResult {
case success(result: ArticleProps)
case error(error: String)
@ -41,40 +53,34 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(query, to: path, headers: headers) { [weak self] result in
switch result {
case let .success(payload):
switch payload.data {
case let .success(result: result):
// store result in core data
self?.persistArticleContent(
htmlContent: result.htmlContent,
slug: slug,
highlights: result.highlights
)
promise(.success(
ArticleContent(
htmlContent: result.htmlContent,
highlightsJSONString: result.highlights.asJSONString
))
)
case .error:
promise(.failure(.unknown))
}
case .failure:
promise(.failure(.unknown))
}
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { [weak self] queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .success(result: result):
self?.persistArticleContent(
htmlContent: result.htmlContent,
slug: slug,
highlights: result.highlights
)
let articleContent = ArticleContent(
htmlContent: result.htmlContent,
highlightsJSONString: result.highlights.asJSONString
)
continuation.resume(returning: articleContent)
case .error:
continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error"))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
extension DataService {
func persistArticleContent(htmlContent: String, slug: String, highlights: [InternalHighlight]) {
backgroundContext.perform {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
@ -101,4 +107,24 @@ extension DataService {
}
}
}
func cachedArticleContent(slug: String) -> ArticleContent? {
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
linkedItemFetchRequest.predicate = NSPredicate(
format: "slug == %@", slug
)
guard let linkedItem = try? persistentContainer.viewContext.fetch(linkedItemFetchRequest).first else { return nil }
guard let htmlContent = linkedItem.htmlContent else { return nil }
let highlights = linkedItem
.highlights
.asArray(of: Highlight.self)
.filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }
return ArticleContent(
htmlContent: htmlContent,
highlightsJSONString: highlights.map { InternalHighlight.make(from: $0) }.asJSONString
)
}
}

View File

@ -5,7 +5,7 @@ import Models
import SwiftGraphQL
public extension DataService {
func labelsPublisher() -> AnyPublisher<[NSManagedObjectID], ServerError> {
func labels() async throws -> [NSManagedObjectID] {
enum QueryResult {
case success(result: [InternalLinkedItemLabel])
case error(error: String)
@ -26,29 +26,26 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
return Deferred {
Future { promise in
send(query, to: path, headers: headers) { result in
switch result {
case let .success(payload):
switch payload.data {
case let .success(result: labels):
if let labelObjectIDs = labels.persist(context: self.backgroundContext) {
promise(.success(labelObjectIDs))
} else {
promise(.failure(.unknown))
}
case .error:
promise(.failure(.unknown))
}
case .failure:
promise(.failure(.unknown))
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network request failed"))
return
}
switch payload.data {
case let .success(result: labels):
if let labelObjectIDs = labels.persist(context: context) {
continuation.resume(returning: labelObjectIDs)
} else {
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
}
case .error:
continuation.resume(throwing: BasicError.message(messageText: "Newsletter Email fetch error"))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View File

@ -1,4 +1,3 @@
import Combine
import CoreData
import Foundation
import Models

View File

@ -29,7 +29,7 @@ struct InternalLinkedItemLabel {
}
func asManagedObject(inContext context: NSManagedObjectContext) -> LinkedItemLabel {
let existingItem = LinkedItemLabel.lookup(byID: id, inContext: context)
let existingItem = LinkedItemLabel.lookup(byName: name, inContext: context)
let label = existingItem ?? LinkedItemLabel(entity: LinkedItemLabel.entity(), insertInto: context)
label.id = id
label.name = name
@ -42,11 +42,12 @@ struct InternalLinkedItemLabel {
extension LinkedItemLabel {
public var unwrappedID: String { id ?? "" }
public var unwrappedName: String { name ?? "" }
static func lookup(byID labelID: String, inContext context: NSManagedObjectContext) -> LinkedItemLabel? {
static func lookup(byName name: String, inContext context: NSManagedObjectContext) -> LinkedItemLabel? {
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "id == %@", labelID
format: "%K == %@", #keyPath(LinkedItemLabel.name), name
)
var label: LinkedItemLabel?

View File

@ -1,4 +1,3 @@
import Combine
import SafariServices
import SwiftUI
import WebKit
@ -8,8 +7,6 @@ public final class WebAppWrapperViewModel: ObservableObject {
case shareHighlight(highlightID: String)
}
public var subscriptions = Set<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
let webViewURLRequest: URLRequest
let baseURL: URL
let rawAuthCookie: String?
@ -43,27 +40,25 @@ public struct WebAppWrapperView: View {
}
public var body: some View {
let webAppView = WebAppView(
request: viewModel.webViewURLRequest,
baseURL: viewModel.baseURL,
rawAuthCookie: viewModel.rawAuthCookie,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
safariWebLink = SafariWebLink(id: UUID(), url: $0)
#endif
},
webViewActionHandler: webViewActionHandler,
navBarVisibilityRatioUpdater: navBarVisibilityRatioUpdater,
annotation: $annotation,
annotationSaveTransactionID: $annotationSaveTransactionID,
sendIncreaseFontSignal: $viewModel.sendIncreaseFontSignal,
sendDecreaseFontSignal: $viewModel.sendDecreaseFontSignal
)
return VStack {
webAppView
VStack {
WebAppView(
request: viewModel.webViewURLRequest,
baseURL: viewModel.baseURL,
rawAuthCookie: viewModel.rawAuthCookie,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
safariWebLink = SafariWebLink(id: UUID(), url: $0)
#endif
},
webViewActionHandler: webViewActionHandler,
navBarVisibilityRatioUpdater: navBarVisibilityRatioUpdater,
annotation: $annotation,
annotationSaveTransactionID: $annotationSaveTransactionID,
sendIncreaseFontSignal: $viewModel.sendIncreaseFontSignal,
sendDecreaseFontSignal: $viewModel.sendDecreaseFontSignal
)
}
.sheet(item: $safariWebLink) {
SafariView(url: $0.url)
@ -92,16 +87,9 @@ public struct WebAppWrapperView: View {
guard let messageBody = message.body as? [String: String] else { return }
guard let actionID = messageBody["actionID"] else { return }
switch actionID {
case "share":
if let highlightId = messageBody["highlightID"] {
viewModel.performActionSubject.send(.shareHighlight(highlightID: highlightId))
}
case "annotate":
if actionID == "annotate" {
annotation = messageBody["annotation"] ?? ""
showHighlightAnnotationModal = true
default:
break
}
}
}