Merge pull request #502 from omnivore-app/feature/labels-syncing
Labels syncing
This commit is contained in:
@ -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 {
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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") }
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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?) {}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user