mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #452 from omnivore-app/feature/core-data-integration
Feature/core data integration
This commit is contained in:
commit
7cbfc96b87
68 changed files with 1804 additions and 1337 deletions
|
|
@ -1251,7 +1251,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
|
||||
|
|
@ -1261,7 +1261,7 @@
|
|||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
|
||||
|
|
@ -1282,7 +1282,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
|
||||
|
|
@ -1292,7 +1292,7 @@
|
|||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1363,7 +1363,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
|
@ -1374,7 +1374,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
|
|
@ -1397,7 +1397,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
|
@ -1408,7 +1408,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1463,7 +1463,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1495,7 +1495,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
|
|
@ -1534,7 +1534,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
|
|
@ -1573,7 +1573,7 @@
|
|||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
|
|
@ -1611,7 +1611,7 @@
|
|||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
|
|
@ -1696,7 +1696,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
|
||||
PRODUCT_NAME = ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
@ -1750,7 +1750,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1778,7 +1778,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.2;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
|
||||
PRODUCT_NAME = ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@
|
|||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
enableThreadSanitizer = "YES"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
|
|
@ -76,6 +77,13 @@
|
|||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "com.apple.CoreData.ConcurrencyDebug"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
|
|
@ -7,76 +8,28 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
@Published public var errorMessage: String?
|
||||
@Published public var readerView: Bool = false
|
||||
|
||||
public var feedItem: FeedItem
|
||||
public var linkedItem: LinkedItem
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
let services: Services
|
||||
|
||||
public init(services: Services, feedItem: FeedItem) {
|
||||
public init(services: Services, linkedItem: LinkedItem) {
|
||||
self.services = services
|
||||
self.feedItem = feedItem
|
||||
self.linkedItem = linkedItem
|
||||
}
|
||||
|
||||
public func loadHighlights(completion onComplete: @escaping ([Highlight]) -> Void) {
|
||||
guard let viewer = services.dataService.currentViewer else { return }
|
||||
|
||||
services.dataService.pdfHighlightsPublisher(username: viewer.username, slug: feedItem.slug).sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case .failure = completion else { return }
|
||||
onComplete(self?.allHighlights(fetchedHighlights: []) ?? [])
|
||||
},
|
||||
receiveValue: { [weak self] highlights in
|
||||
onComplete(self?.allHighlights(fetchedHighlights: highlights) ?? [])
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func allHighlights(fetchedHighlights: [Highlight]) -> [Highlight] {
|
||||
var resultSet = [String: Highlight]()
|
||||
|
||||
for highlight in services.dataService.cachedHighlights(pdfID: feedItem.id) {
|
||||
resultSet[highlight.id] = highlight
|
||||
}
|
||||
for highlight in fetchedHighlights {
|
||||
resultSet[highlight.id] = highlight
|
||||
}
|
||||
for highlightId in services.dataService.fetchRemovedHighlightIds(pdfID: feedItem.id) {
|
||||
resultSet.removeValue(forKey: highlightId)
|
||||
}
|
||||
return Array(resultSet.values)
|
||||
public func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {
|
||||
onComplete(linkedItem.highlights.asArray(of: Highlight.self).map { $0.patch ?? "" })
|
||||
}
|
||||
|
||||
public func createHighlight(shortId: String, highlightID: String, quote: String, patch: String) {
|
||||
services.dataService.persistHighlight(
|
||||
pdfID: feedItem.id,
|
||||
highlight: Highlight(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
quote: quote,
|
||||
prefix: nil,
|
||||
suffix: nil,
|
||||
patch: patch,
|
||||
annotation: nil,
|
||||
createdByMe: true
|
||||
)
|
||||
_ = services.dataService.createHighlight(
|
||||
shortId: shortId,
|
||||
highlightID: highlightID,
|
||||
quote: quote,
|
||||
patch: patch,
|
||||
articleId: linkedItem.unwrappedID
|
||||
)
|
||||
|
||||
services.dataService
|
||||
.createHighlightPublisher(
|
||||
shortId: shortId,
|
||||
highlightID: highlightID,
|
||||
quote: quote,
|
||||
patch: patch,
|
||||
articleId: feedItem.id
|
||||
)
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.errorMessage = error.localizedDescription
|
||||
} receiveValue: { value in
|
||||
print("highlight value", value)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
public func mergeHighlight(
|
||||
|
|
@ -86,84 +39,40 @@ public final class PDFViewerViewModel: ObservableObject {
|
|||
patch: String,
|
||||
overlapHighlightIdList: [String]
|
||||
) {
|
||||
services.dataService.persistHighlight(
|
||||
pdfID: feedItem.id,
|
||||
highlight: Highlight(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
quote: quote,
|
||||
prefix: nil,
|
||||
suffix: nil,
|
||||
patch: patch,
|
||||
annotation: nil,
|
||||
createdByMe: true
|
||||
)
|
||||
_ = services.dataService.mergeHighlights(
|
||||
shortId: shortId,
|
||||
highlightID: highlightID,
|
||||
quote: quote,
|
||||
patch: patch,
|
||||
articleId: linkedItem.unwrappedID,
|
||||
overlapHighlightIdList: overlapHighlightIdList
|
||||
)
|
||||
|
||||
removeLocalHighlights(highlightIds: overlapHighlightIdList)
|
||||
|
||||
services.dataService
|
||||
.mergeHighlightPublisher(
|
||||
shortId: shortId,
|
||||
highlightID: highlightID,
|
||||
quote: quote,
|
||||
patch: patch,
|
||||
articleId: feedItem.id,
|
||||
overlapHighlightIdList: overlapHighlightIdList
|
||||
)
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.errorMessage = error.localizedDescription
|
||||
} receiveValue: { value in
|
||||
print("highlight value", value)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
public func removeHighlights(highlightIds: [String]) {
|
||||
removeLocalHighlights(highlightIds: highlightIds)
|
||||
|
||||
highlightIds.forEach { highlightId in
|
||||
services.dataService.deleteHighlightPublisher(highlightId: highlightId)
|
||||
.sink { [weak self] completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
self?.errorMessage = error.localizedDescription
|
||||
} receiveValue: { value in
|
||||
print("remove highlight value", value)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
highlightIds.forEach { highlightID in
|
||||
services.dataService.deleteHighlight(highlightID: highlightID)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateItemReadProgress(percent: Double, anchorIndex: Int) {
|
||||
services.dataService
|
||||
.updateArticleReadingProgressPublisher(
|
||||
itemID: feedItem.id,
|
||||
readingProgress: percent,
|
||||
anchorIndex: anchorIndex
|
||||
)
|
||||
.sink { completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
print(error)
|
||||
} receiveValue: { _ in
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
services.dataService.updateLinkReadingProgress(
|
||||
itemID: linkedItem.unwrappedID,
|
||||
readingProgress: percent,
|
||||
anchorIndex: anchorIndex
|
||||
)
|
||||
}
|
||||
|
||||
public func highlightShareURL(shortId: String) -> URL? {
|
||||
let baseURL = services.dataService.appEnvironment.serverBaseURL
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
||||
|
||||
if let viewer = services.dataService.currentViewer?.username {
|
||||
components?.path = "/\(viewer)/\(feedItem.slug)/highlights/\(shortId)"
|
||||
if let username = services.dataService.currentViewer?.username {
|
||||
components?.path = "/\(username)/\(linkedItem.unwrappedSlug)/highlights/\(shortId)"
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return components?.url
|
||||
}
|
||||
|
||||
private func removeLocalHighlights(highlightIds: [String]) {
|
||||
services.dataService.removeHighlights(pdfID: feedItem.id, highlightIds: highlightIds)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import Views
|
|||
struct FeedCardNavigationLink: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
let item: FeedItem
|
||||
let item: LinkedItem
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ struct FeedCardNavigationLink: View {
|
|||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
viewModel.itemAppeared(item: item, dataService: dataService)
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
FeedCard(item: item)
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ struct GridCardNavigationLink: View {
|
|||
|
||||
@State private var scale = 1.0
|
||||
|
||||
let item: FeedItem
|
||||
let item: LinkedItem
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
|
||||
@Binding var isContextMenuOpen: Bool
|
||||
|
|
@ -60,7 +60,7 @@ struct GridCardNavigationLink: View {
|
|||
}
|
||||
})
|
||||
.onAppear {
|
||||
viewModel.itemAppeared(item: item, dataService: dataService)
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
}
|
||||
.aspectRatio(1.8, contentMode: .fill)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import Views
|
|||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = UIDevice.isIPhone
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
HomeFeedView(
|
||||
|
|
@ -19,7 +23,7 @@ import Views
|
|||
viewModel: viewModel
|
||||
)
|
||||
.refreshable {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.searchable(
|
||||
text: $viewModel.searchTerm,
|
||||
|
|
@ -35,18 +39,16 @@ import Views
|
|||
.onChange(of: viewModel.searchTerm) { _ in
|
||||
// Maybe we should debounce this, but
|
||||
// it feels like it works ok without
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.selectedLabels) { _ in
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onSubmit(of: .search) {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
|
||||
ApplyLabelsView(mode: .item(item)) { labels in
|
||||
viewModel.updateLabels(itemID: item.id, labels: labels)
|
||||
}
|
||||
ApplyLabelsView(mode: .item(item)) { _ in }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
|
|
@ -54,17 +56,18 @@ import Views
|
|||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
||||
// Don't refresh the list if the user is currently reading an article
|
||||
if viewModel.selectedLinkItem == nil {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushFeedItem"))) { notification in
|
||||
if let feedItem = notification.userInfo?["feedItem"] as? FeedItem {
|
||||
viewModel.pushFeedItem(item: feedItem)
|
||||
viewModel.selectedLinkItem = feedItem
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in
|
||||
guard let jsonArticle = notification.userInfo?["article"] as? JSONArticle else { return }
|
||||
guard let objectID = dataService.persist(jsonArticle: jsonArticle) else { return }
|
||||
guard let linkedItem = dataService.viewContext.object(with: objectID) as? LinkedItem else { return }
|
||||
viewModel.pushFeedItem(item: linkedItem)
|
||||
viewModel.selectedLinkItem = linkedItem
|
||||
}
|
||||
.formSheet(isPresented: $viewModel.snoozePresented) {
|
||||
SnoozeView(snoozePresented: $viewModel.snoozePresented, itemToSnooze: $viewModel.itemToSnooze) {
|
||||
SnoozeView(snoozePresented: $viewModel.snoozePresented, itemToSnoozeID: $viewModel.itemToSnoozeID) {
|
||||
viewModel.snoozeUntil(
|
||||
dataService: dataService,
|
||||
linkId: $0.feedItemId,
|
||||
|
|
@ -73,14 +76,11 @@ import Views
|
|||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
.onAppear { // TODO: use task instead
|
||||
if viewModel.items.isEmpty {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.selectedLinkItem) { _ in
|
||||
viewModel.commitItemUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ import Views
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@Binding var prefersListLayout: Bool
|
||||
|
||||
@State private var itemToRemove: FeedItem?
|
||||
@State private var itemToRemove: LinkedItem?
|
||||
@State private var confirmationShown = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
|
@ -165,7 +165,11 @@ import Views
|
|||
)
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)
|
||||
viewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
objectID: item.objectID,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
}
|
||||
}, label: {
|
||||
Label(
|
||||
|
|
@ -182,7 +186,7 @@ import Views
|
|||
)
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnooze = item
|
||||
viewModel.itemToSnoozeID = item.id
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text("Snooze") } icon: { Image.moon }
|
||||
|
|
@ -193,7 +197,7 @@ import Views
|
|||
if !item.isArchived {
|
||||
Button {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true)
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: true)
|
||||
}
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
|
|
@ -201,7 +205,7 @@ import Views
|
|||
} else {
|
||||
Button {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false)
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: false)
|
||||
}
|
||||
} label: {
|
||||
Label("Unarchive", systemImage: "tray.and.arrow.down.fill")
|
||||
|
|
@ -223,7 +227,7 @@ import Views
|
|||
Button("Remove Link", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
|
||||
viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID)
|
||||
}
|
||||
}
|
||||
self.itemToRemove = nil
|
||||
|
|
@ -233,7 +237,7 @@ import Views
|
|||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnooze = item
|
||||
viewModel.itemToSnoozeID = item.id
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text("Snooze") } icon: { Image.moon }
|
||||
|
|
@ -260,25 +264,22 @@ import Views
|
|||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.sendProgressUpdates = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeFeedGridView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@State private var itemToRemove: FeedItem?
|
||||
@State private var itemToRemove: LinkedItem?
|
||||
@State private var confirmationShown = false
|
||||
@State var isContextMenuOpen = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
func contextMenuActionHandler(item: FeedItem, action: GridCardAction) {
|
||||
func contextMenuActionHandler(item: LinkedItem, action: GridCardAction) {
|
||||
switch action {
|
||||
case .toggleArchiveStatus:
|
||||
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived)
|
||||
case .delete:
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
|
|
@ -287,6 +288,10 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 24)], spacing: 24) {
|
||||
|
|
@ -301,7 +306,7 @@ import Views
|
|||
Button("Remove Link", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
|
||||
viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID)
|
||||
}
|
||||
}
|
||||
self.itemToRemove = nil
|
||||
|
|
@ -322,7 +327,7 @@ import Views
|
|||
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
|
||||
DispatchQueue.main.async {
|
||||
if !viewModel.isLoading, offset > 240 {
|
||||
viewModel.loadItems(dataService: dataService, isRefresh: true)
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -331,9 +336,6 @@ import Views
|
|||
LoadingSection()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.sendProgressUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import Views
|
|||
#if os(macOS)
|
||||
struct HomeFeedView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@State private var itemToRemove: FeedItem?
|
||||
@State private var itemToRemove: LinkedItem?
|
||||
@State private var confirmationShown = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
|
@ -40,7 +40,7 @@ import Views
|
|||
)
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnooze = item
|
||||
viewModel.itemToSnoozeID = item.id
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text("Snooze") } icon: { Image.moon }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
|
@ -8,24 +9,17 @@ import Views
|
|||
@MainActor final class HomeFeedViewModel: ObservableObject {
|
||||
var currentDetailViewModel: LinkItemDetailViewModel?
|
||||
|
||||
/// Track progress updates to be committed when user navigates back to grid view
|
||||
var uncommittedReadingProgressUpdates = [String: Double]()
|
||||
|
||||
/// Track label updates to be committed when user navigates back to grid view
|
||||
var uncommittedLabelUpdates = [String: [FeedItemLabel]]()
|
||||
|
||||
@Published var items = [FeedItem]()
|
||||
@Published var items = [LinkedItem]()
|
||||
@Published var isLoading = false
|
||||
@Published var showPushNotificationPrimer = false
|
||||
@Published var itemUnderLabelEdit: FeedItem?
|
||||
@Published var itemUnderLabelEdit: LinkedItem?
|
||||
@Published var searchTerm = ""
|
||||
@Published var selectedLabels = [FeedItemLabel]()
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var snoozePresented = false
|
||||
@Published var itemToSnooze: FeedItem?
|
||||
@Published var selectedLinkItem: FeedItem?
|
||||
@Published var itemToSnoozeID: String?
|
||||
@Published var selectedLinkItem: LinkedItem?
|
||||
|
||||
var cursor: String?
|
||||
var sendProgressUpdates = false
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
// responses in the right order
|
||||
|
|
@ -36,114 +30,132 @@ import Views
|
|||
|
||||
init() {}
|
||||
|
||||
func itemAppeared(item: FeedItem, dataService: DataService) {
|
||||
func itemAppeared(item: LinkedItem, dataService: DataService) async {
|
||||
if isLoading { return }
|
||||
let itemIndex = items.firstIndex(where: { $0.id == item.id })
|
||||
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
|
||||
|
||||
// Check if user has scrolled to the last five items in the list
|
||||
if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 {
|
||||
Task { await loadItems(dataService: dataService, isRefresh: false) }
|
||||
await loadItems(dataService: dataService, isRefresh: false)
|
||||
}
|
||||
}
|
||||
|
||||
func pushFeedItem(item: FeedItem) {
|
||||
func pushFeedItem(item: LinkedItem) {
|
||||
items.insert(item, at: 0)
|
||||
}
|
||||
|
||||
func loadItems(dataService: DataService, isRefresh: Bool) {
|
||||
// Clear offline highlights since we'll be populating new FeedItems with the correct highlights set
|
||||
dataService.clearHighlights()
|
||||
|
||||
func loadItems(dataService: DataService, isRefresh: Bool) async {
|
||||
let thisSearchIdx = searchIdx
|
||||
searchIdx += 1
|
||||
|
||||
isLoading = true
|
||||
|
||||
// Cache the viewer
|
||||
|
||||
if dataService.currentViewer == nil {
|
||||
Task { _ = try? await dataService.fetchViewer() }
|
||||
}
|
||||
|
||||
dataService.libraryItemsPublisher(
|
||||
let queryResult = try? await dataService.fetchLinkedItems(
|
||||
limit: 10,
|
||||
sortDescending: true,
|
||||
searchQuery: searchQuery,
|
||||
cursor: isRefresh ? nil : cursor
|
||||
)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case .failure = completion else { return }
|
||||
self?.isLoading = false
|
||||
},
|
||||
receiveValue: { [weak self] result in
|
||||
// Search results aren't guaranteed to return in order so this
|
||||
// will discard old results that are returned while a user is typing.
|
||||
// For example if a user types 'Canucks', often the search results
|
||||
// for 'C' are returned after 'Canucks' because it takes the backend
|
||||
// much longer to compute.
|
||||
if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 {
|
||||
return
|
||||
|
||||
// Search results aren't guaranteed to return in order so this
|
||||
// will discard old results that are returned while a user is typing.
|
||||
// For example if a user types 'Canucks', often the search results
|
||||
// for 'C' are returned after 'Canucks' because it takes the backend
|
||||
// much longer to compute.
|
||||
if thisSearchIdx > 0, thisSearchIdx <= receivedIdx {
|
||||
return
|
||||
}
|
||||
|
||||
if let queryResult = queryResult {
|
||||
let newItems: [LinkedItem] = {
|
||||
var itemObjects = [LinkedItem]()
|
||||
dataService.viewContext.performAndWait {
|
||||
itemObjects = queryResult.items.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem }
|
||||
}
|
||||
return itemObjects
|
||||
}()
|
||||
items = isRefresh ? newItems : items + newItems
|
||||
isLoading = false
|
||||
receivedIdx = thisSearchIdx
|
||||
cursor = queryResult.cursor
|
||||
dataService.prefetchPages(itemSlugs: newItems.map(\.unwrappedSlug))
|
||||
} else if searchTermIsEmpty {
|
||||
await dataService.viewContext.perform {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)]
|
||||
fetchRequest.predicate = self.itemRequestPredicate
|
||||
// // TODO: Filter on label
|
||||
|
||||
dataService.prefetchPages(items: result.items)
|
||||
|
||||
self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items
|
||||
self?.isLoading = false
|
||||
self?.receivedIdx = thisSearchIdx
|
||||
self?.cursor = result.cursor
|
||||
if let fetchedItems = try? dataService.viewContext.fetch(fetchRequest) {
|
||||
self.items = fetchedItems
|
||||
self.cursor = nil
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var itemRequestPredicate: NSPredicate {
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) {
|
||||
isLoading = true
|
||||
|
||||
// First remove the link from the internal list,
|
||||
// then make a call to remove it. The isLoading block should
|
||||
// prevent our local change from being overwritten, but we
|
||||
// might need to cache a local list of archived links
|
||||
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
|
||||
items.remove(at: itemIndex)
|
||||
if searchTerm.contains("in:all") {
|
||||
// include everything undeleted
|
||||
return undeletedPredicate
|
||||
}
|
||||
|
||||
dataService.archiveLinkPublisher(itemID: linkId, archived: archived)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case .failure = completion else { return }
|
||||
self?.isLoading = false
|
||||
NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link")
|
||||
},
|
||||
receiveValue: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
Snackbar.show(message: archived ? "Link archived" : "Link moved to Inbox")
|
||||
}
|
||||
if searchTerm.contains("in:archive") {
|
||||
let inArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func removeLink(dataService: DataService, linkId: String) {
|
||||
isLoading = true
|
||||
|
||||
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
|
||||
items.remove(at: itemIndex)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate])
|
||||
}
|
||||
|
||||
dataService.removeLinkPublisher(itemID: linkId)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
guard case .failure = completion else { return }
|
||||
self?.isLoading = false
|
||||
Snackbar.show(message: "Failed to remove link")
|
||||
},
|
||||
receiveValue: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
Snackbar.show(message: "Link removed")
|
||||
}
|
||||
if searchTerm.contains("type:file") {
|
||||
// include pdf only
|
||||
let isPDFPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF"
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate])
|
||||
}
|
||||
|
||||
// default to "in:inbox" (non-archived items)
|
||||
let notInArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate])
|
||||
}
|
||||
|
||||
// Exclude filters when testing if user has enetered a search term
|
||||
private var searchTermIsEmpty: Bool {
|
||||
searchTerm
|
||||
.replacingOccurrences(of: "in:inbox", with: "")
|
||||
.replacingOccurrences(of: "in:all", with: "")
|
||||
.replacingOccurrences(of: "in:archive", with: "")
|
||||
.replacingOccurrences(of: "type:file", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
.isEmpty
|
||||
}
|
||||
|
||||
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
|
||||
// TODO: remove this by making list always fetch from Coredata
|
||||
guard let itemIndex = items.firstIndex(where: { $0.objectID == objectID }) else { return }
|
||||
items.remove(at: itemIndex)
|
||||
dataService.archiveLink(objectID: objectID, archived: archived)
|
||||
Snackbar.show(message: archived ? "Link archived" : "Link moved to Inbox")
|
||||
}
|
||||
|
||||
func removeLink(dataService: DataService, objectID: NSManagedObjectID) {
|
||||
guard let itemIndex = items.firstIndex(where: { $0.objectID == objectID }) else { return }
|
||||
items.remove(at: itemIndex)
|
||||
Snackbar.show(message: "Link removed")
|
||||
dataService.removeLink(objectID: objectID)
|
||||
}
|
||||
|
||||
func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) {
|
||||
|
|
@ -173,41 +185,6 @@ import Views
|
|||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
/// Update `FeedItem`s with the cached reading progress and label values so it can animate when the
|
||||
/// user navigates back to the grid view (and also avoid mutations of the grid items
|
||||
/// that can cause the `NavigationView` to pop.
|
||||
func commitItemUpdates() {
|
||||
for (key, value) in uncommittedReadingProgressUpdates {
|
||||
updateProgress(itemID: key, progress: value)
|
||||
}
|
||||
for (key, value) in uncommittedLabelUpdates {
|
||||
updateLabels(itemID: key, labels: value)
|
||||
}
|
||||
uncommittedReadingProgressUpdates = [:]
|
||||
uncommittedLabelUpdates = [:]
|
||||
}
|
||||
|
||||
private func updateProgress(itemID: String, progress: Double) {
|
||||
guard sendProgressUpdates, let item = items.first(where: { $0.id == itemID }) else { return }
|
||||
if let index = items.firstIndex(of: item) {
|
||||
items[index].readingProgress = progress
|
||||
}
|
||||
}
|
||||
|
||||
func updateLabels(itemID: String, labels: [FeedItemLabel]) {
|
||||
// If item is being being displayed then delay the state update of labels until
|
||||
// user is no longer reading the item.
|
||||
if selectedLinkItem != nil {
|
||||
uncommittedLabelUpdates[itemID] = labels
|
||||
return
|
||||
}
|
||||
|
||||
guard let item = items.first(where: { $0.id == itemID }) else { return }
|
||||
if let index = items.firstIndex(of: item) {
|
||||
items[index].labels = labels
|
||||
}
|
||||
}
|
||||
|
||||
private var searchQuery: String? {
|
||||
if searchTerm.isEmpty, selectedLabels.isEmpty {
|
||||
return nil
|
||||
|
|
@ -217,7 +194,7 @@ import Views
|
|||
|
||||
if !selectedLabels.isEmpty {
|
||||
query.append(" label:")
|
||||
query.append(selectedLabels.map(\.name).joined(separator: ","))
|
||||
query.append(selectedLabels.map { $0.name ?? "" }.joined(separator: ","))
|
||||
}
|
||||
|
||||
return query
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import Views
|
|||
|
||||
struct ApplyLabelsView: View {
|
||||
enum Mode {
|
||||
case item(FeedItem)
|
||||
case list([FeedItemLabel])
|
||||
case item(LinkedItem)
|
||||
case list([LinkedItemLabel])
|
||||
|
||||
var navTitle: String {
|
||||
switch self {
|
||||
|
|
@ -28,12 +28,11 @@ struct ApplyLabelsView: View {
|
|||
}
|
||||
|
||||
let mode: Mode
|
||||
let commitLabelChanges: ([FeedItemLabel]) -> Void
|
||||
let commitLabelChanges: ([LinkedItemLabel]) -> Void
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@StateObject var viewModel = LabelsViewModel()
|
||||
@State private var labelSearchFilter = ""
|
||||
|
||||
var innerBody: some View {
|
||||
List {
|
||||
|
|
@ -41,7 +40,7 @@ struct ApplyLabelsView: View {
|
|||
if viewModel.selectedLabels.isEmpty {
|
||||
Text("No labels are currently assigned.")
|
||||
}
|
||||
ForEach(viewModel.selectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in
|
||||
ForEach(viewModel.selectedLabels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label)
|
||||
Spacer()
|
||||
|
|
@ -57,7 +56,7 @@ struct ApplyLabelsView: View {
|
|||
}
|
||||
}
|
||||
Section(header: Text("Available Labels")) {
|
||||
ForEach(viewModel.unselectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in
|
||||
ForEach(viewModel.unselectedLabels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label)
|
||||
Spacer()
|
||||
|
|
@ -101,7 +100,7 @@ struct ApplyLabelsView: View {
|
|||
action: {
|
||||
switch mode {
|
||||
case let .item(feedItem):
|
||||
viewModel.saveItemLabelChanges(itemID: feedItem.id, dataService: dataService) { labels in
|
||||
viewModel.saveItemLabelChanges(itemID: feedItem.unwrappedID, dataService: dataService) { labels in
|
||||
commitLabelChanges(labels)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
|
|
@ -128,7 +127,7 @@ struct ApplyLabelsView: View {
|
|||
#if os(iOS)
|
||||
innerBody
|
||||
.searchable(
|
||||
text: $labelSearchFilter,
|
||||
text: $viewModel.labelSearchFilter,
|
||||
placement: .navigationBarDrawer(displayMode: .always)
|
||||
)
|
||||
#else
|
||||
|
|
@ -147,11 +146,11 @@ struct ApplyLabelsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private extension Sequence where Element == FeedItemLabel {
|
||||
func applySearchFilter(_ searchFilter: String) -> [FeedItemLabel] {
|
||||
private extension Sequence where Element == LinkedItemLabel {
|
||||
func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] {
|
||||
if searchFilter.isEmpty {
|
||||
return map { $0 } // return the identity of the sequence
|
||||
}
|
||||
return filter { $0.name.lowercased().contains(searchFilter.lowercased()) }
|
||||
return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,40 +7,39 @@ import Views
|
|||
final class LabelsViewModel: ObservableObject {
|
||||
private var hasLoadedInitialLabels = false
|
||||
@Published var isLoading = false
|
||||
@Published var selectedLabels = [FeedItemLabel]()
|
||||
@Published var unselectedLabels = [FeedItemLabel]()
|
||||
@Published var labels = [FeedItemLabel]()
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var unselectedLabels = [LinkedItemLabel]()
|
||||
@Published var labels = [LinkedItemLabel]()
|
||||
@Published var showCreateEmailModal = false
|
||||
@Published var labelSearchFilter = ""
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
/// Loads initial set of labels when a edit labels list is displayed
|
||||
/// - Parameters:
|
||||
/// - dataService: `DataService` reference
|
||||
/// - item: Optional `FeedItem` for applying labels to a single item
|
||||
/// - initiallySelectedLabels: Optional `[FeedItemLabel]` for filtering a list of items
|
||||
func loadLabels(dataService: DataService, item: FeedItem? = nil, initiallySelectedLabels: [FeedItemLabel]? = nil) {
|
||||
func loadLabels(
|
||||
dataService: DataService,
|
||||
item: LinkedItem? = nil,
|
||||
initiallySelectedLabels: [LinkedItemLabel]? = nil
|
||||
) {
|
||||
guard !hasLoadedInitialLabels else { return }
|
||||
isLoading = true
|
||||
|
||||
dataService.labelsPublisher().sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { [weak self] allLabels in
|
||||
self?.isLoading = false
|
||||
self?.labels = allLabels
|
||||
self?.hasLoadedInitialLabels = true
|
||||
if let item = item {
|
||||
self?.selectedLabels = item.labels
|
||||
self?.unselectedLabels = allLabels.filter { label in
|
||||
!item.labels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
}
|
||||
if let initiallySelectedLabels = initiallySelectedLabels {
|
||||
self?.selectedLabels = initiallySelectedLabels
|
||||
self?.unselectedLabels = allLabels.filter { label in
|
||||
!initiallySelectedLabels.contains(where: { $0.id == label.id })
|
||||
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
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
|
|
@ -57,10 +56,12 @@ final class LabelsViewModel: ObservableObject {
|
|||
receiveCompletion: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
},
|
||||
receiveValue: { [weak self] result in
|
||||
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?.labels.insert(result, at: 0)
|
||||
self?.unselectedLabels.insert(result, at: 0)
|
||||
self?.showCreateEmailModal = false
|
||||
}
|
||||
)
|
||||
|
|
@ -82,23 +83,31 @@ final class LabelsViewModel: ObservableObject {
|
|||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func saveItemLabelChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) {
|
||||
func saveItemLabelChanges(
|
||||
itemID: String,
|
||||
dataService: DataService,
|
||||
onComplete: @escaping ([LinkedItemLabel]) -> Void
|
||||
) {
|
||||
isLoading = true
|
||||
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink(
|
||||
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.unwrappedID)).sink(
|
||||
receiveCompletion: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
},
|
||||
receiveValue: { onComplete($0) }
|
||||
receiveValue: { labelIDs in
|
||||
onComplete(
|
||||
labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
|
||||
)
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func addLabelToItem(_ label: FeedItemLabel) {
|
||||
func addLabelToItem(_ label: LinkedItemLabel) {
|
||||
selectedLabels.insert(label, at: 0)
|
||||
unselectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
|
||||
func removeLabelFromItem(_ label: FeedItemLabel) {
|
||||
func removeLabelFromItem(_ label: LinkedItemLabel) {
|
||||
unselectedLabels.insert(label, at: 0)
|
||||
selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,43 +6,39 @@ import Utils
|
|||
import Views
|
||||
|
||||
enum PDFProvider {
|
||||
static var pdfViewerProvider: ((URL, FeedItem) -> AnyView)?
|
||||
static var pdfViewerProvider: ((URL, LinkedItem) -> AnyView)?
|
||||
}
|
||||
|
||||
@MainActor final class LinkItemDetailViewModel: ObservableObject {
|
||||
let homeFeedViewModel: HomeFeedViewModel
|
||||
@Published var item: FeedItem
|
||||
@Published var item: LinkedItem
|
||||
@Published var webAppWrapperViewModel: WebAppWrapperViewModel?
|
||||
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
init(item: FeedItem, homeFeedViewModel: HomeFeedViewModel) {
|
||||
init(item: LinkedItem, homeFeedViewModel: HomeFeedViewModel) {
|
||||
self.item = item
|
||||
self.homeFeedViewModel = homeFeedViewModel
|
||||
}
|
||||
|
||||
func handleArchiveAction(dataService: DataService) {
|
||||
homeFeedViewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)
|
||||
homeFeedViewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
objectID: item.objectID,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
}
|
||||
|
||||
func handleDeleteAction(dataService: DataService) {
|
||||
homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id)
|
||||
homeFeedViewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
}
|
||||
|
||||
func updateItemReadStatus(dataService: DataService) {
|
||||
dataService
|
||||
.updateArticleReadingProgressPublisher(
|
||||
itemID: item.id,
|
||||
readingProgress: item.isRead ? 0 : 100,
|
||||
anchorIndex: 0
|
||||
)
|
||||
.sink { completion in
|
||||
guard case let .failure(error) = completion else { return }
|
||||
print(error)
|
||||
} receiveValue: { [weak self] feedItem in
|
||||
self?.item.readingProgress = feedItem.readingProgress
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
dataService.updateLinkReadingProgress(
|
||||
itemID: item.unwrappedID,
|
||||
readingProgress: item.isRead ? 0 : 100,
|
||||
anchorIndex: 0
|
||||
)
|
||||
}
|
||||
|
||||
func loadWebAppWrapper(dataService: DataService, rawAuthCookie: String?) async {
|
||||
|
|
@ -51,12 +47,20 @@ enum PDFProvider {
|
|||
return currentViewer
|
||||
}
|
||||
|
||||
return try? await dataService.fetchViewer()
|
||||
guard let viewerObjectID = try? await dataService.fetchViewer() else { return nil }
|
||||
|
||||
var result: Viewer?
|
||||
|
||||
await dataService.viewContext.perform {
|
||||
result = dataService.viewContext.object(with: viewerObjectID) as? Viewer
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
if let viewer = viewer {
|
||||
createWebAppWrapperViewModel(
|
||||
username: viewer.username,
|
||||
username: viewer.unwrappedUsername,
|
||||
dataService: dataService,
|
||||
rawAuthCookie: rawAuthCookie
|
||||
)
|
||||
|
|
@ -68,7 +72,7 @@ enum PDFProvider {
|
|||
|
||||
let urlRequest = URLRequest.webRequest(
|
||||
baseURL: dataService.appEnvironment.webAppBaseURL,
|
||||
urlPath: "/app/\(username)/\(item.slug)",
|
||||
urlPath: "/app/\(username)/\(item.unwrappedSlug)",
|
||||
queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"]
|
||||
)
|
||||
|
||||
|
|
@ -78,12 +82,10 @@ enum PDFProvider {
|
|||
rawAuthCookie: rawAuthCookie
|
||||
)
|
||||
|
||||
newWebAppWrapperViewModel.performActionSubject.sink { [weak self] action in
|
||||
newWebAppWrapperViewModel.performActionSubject.sink { action in
|
||||
switch action {
|
||||
case let .shareHighlight(highlightID):
|
||||
print("show share modal for highlight with id: \(highlightID)")
|
||||
case let .updateReadingProgess(progress: progress):
|
||||
self?.homeFeedViewModel.uncommittedReadingProgressUpdates[self?.item.id ?? ""] = Double(progress)
|
||||
}
|
||||
}
|
||||
.store(in: &newWebAppWrapperViewModel.subscriptions)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ final class NewsletterEmailsViewModel: ObservableObject {
|
|||
|
||||
dataService.newsletterEmailsPublisher().sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { [weak self] result in
|
||||
receiveValue: { [weak self] objectIDs in
|
||||
self?.isLoading = false
|
||||
self?.emails = result
|
||||
dataService.viewContext.perform {
|
||||
self?.emails = objectIDs.compactMap { dataService.viewContext.object(with: $0) as? NewsletterEmail }
|
||||
}
|
||||
self?.hasLoadedInitialEmails = true
|
||||
}
|
||||
)
|
||||
|
|
@ -32,9 +34,13 @@ final class NewsletterEmailsViewModel: ObservableObject {
|
|||
receiveCompletion: { [weak self] _ in
|
||||
self?.isLoading = false
|
||||
},
|
||||
receiveValue: { [weak self] result in
|
||||
receiveValue: { [weak self] objectID in
|
||||
self?.isLoading = false
|
||||
self?.emails.insert(result, at: 0)
|
||||
dataService.viewContext.perform {
|
||||
if let item = dataService.viewContext.object(with: objectID) as? NewsletterEmail {
|
||||
self?.emails.insert(item, at: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
|
|
@ -97,7 +103,7 @@ struct NewsletterEmailsView: View {
|
|||
|
||||
Snackbar.show(message: "Email copied")
|
||||
},
|
||||
label: { Text(newsletterEmail.email) }
|
||||
label: { Text(newsletterEmail.unwrappedEmail) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,24 @@ import Views
|
|||
}
|
||||
|
||||
func loadProfileData(dataService: DataService) async {
|
||||
guard let viewer = try? await dataService.fetchViewer() else { return }
|
||||
if let currentViewer = dataService.currentViewer {
|
||||
loadProfileCardData(viewer: currentViewer)
|
||||
return
|
||||
}
|
||||
|
||||
guard let viewerObjectID = try? await dataService.fetchViewer() else { return }
|
||||
|
||||
await dataService.viewContext.perform {
|
||||
if let viewer = dataService.viewContext.object(with: viewerObjectID) as? Viewer {
|
||||
self.loadProfileCardData(viewer: viewer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProfileCardData(viewer: Viewer) {
|
||||
profileCardData = ProfileCardData(
|
||||
name: viewer.name,
|
||||
username: viewer.username,
|
||||
name: viewer.unwrappedName,
|
||||
username: viewer.unwrappedUsername,
|
||||
imageURL: viewer.profileImageURL.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public struct RootView: View {
|
|||
InnerRootView(viewModel: viewModel)
|
||||
.environmentObject(viewModel.services.authenticator)
|
||||
.environmentObject(viewModel.services.dataService)
|
||||
.environment(\.managedObjectContext, viewModel.services.dataService.viewContext)
|
||||
.onAppear {
|
||||
if let pdfViewerProvider = pdfViewerProvider {
|
||||
viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider)
|
||||
|
|
@ -59,10 +60,17 @@ struct InnerRootView: View {
|
|||
}
|
||||
})
|
||||
#endif
|
||||
.snackBar(
|
||||
isShowing: $viewModel.showSnackbar,
|
||||
text: Text(viewModel.snackbarMessage ?? "")
|
||||
)
|
||||
.snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage)
|
||||
// Schedule the dismissal every time we present the snackbar.
|
||||
.onChange(of: viewModel.showSnackbar) { newValue in
|
||||
if newValue {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
viewModel.showSnackbar = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.customAlert(isPresented: $viewModel.showPushNotificationPrimer) {
|
||||
pushNotificationPrimerView
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ public final class RootViewModel: ObservableObject {
|
|||
func configurePDFProvider(pdfViewerProvider: @escaping (URL, PDFViewerViewModel) -> AnyView) {
|
||||
guard PDFProvider.pdfViewerProvider == nil else { return }
|
||||
|
||||
PDFProvider.pdfViewerProvider = { [weak self] url, feedItem in
|
||||
PDFProvider.pdfViewerProvider = { [weak self] url, linkedItem in
|
||||
guard let self = self else { return AnyView(Text("")) }
|
||||
return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, feedItem: feedItem))
|
||||
return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, linkedItem: linkedItem))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,9 +64,11 @@ public final class RootViewModel: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
if let viewer = try? await services.dataService.fetchViewer() {
|
||||
let path = linkRequestPath(username: viewer.username, requestID: linkRequestID)
|
||||
webLinkPath = SafariWebLinkPath(id: UUID(), path: path)
|
||||
if let viewerObjectID = try? await services.dataService.fetchViewer() {
|
||||
if let viewer = services.dataService.viewContext.object(with: viewerObjectID) as? Viewer {
|
||||
let path = linkRequestPath(username: viewer.unwrappedUsername, requestID: linkRequestID)
|
||||
webLinkPath = SafariWebLinkPath(id: UUID(), path: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import WebKit
|
|||
|
||||
#if os(iOS)
|
||||
struct WebReader: UIViewRepresentable {
|
||||
let articleContent: ArticleContent
|
||||
let item: FeedItem
|
||||
let htmlContent: String
|
||||
let highlightsJSONString: String
|
||||
let item: LinkedItem
|
||||
let openLinkAction: (URL) -> Void
|
||||
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
|
||||
let navBarVisibilityRatioUpdater: (Double) -> Void
|
||||
|
|
@ -99,7 +100,8 @@ import WebKit
|
|||
func loadContent(webView: WKWebView) {
|
||||
webView.loadHTMLString(
|
||||
WebReaderContent(
|
||||
articleContent: articleContent,
|
||||
htmlContent: htmlContent,
|
||||
highlightsJSONString: highlightsJSONString,
|
||||
item: item,
|
||||
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
|
||||
fontSize: fontSize()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import WebKit
|
|||
|
||||
#if os(iOS)
|
||||
struct WebReaderContainerView: View {
|
||||
let item: FeedItem
|
||||
let item: LinkedItem
|
||||
let homeFeedViewModel: HomeFeedViewModel
|
||||
|
||||
@State private var showFontSizePopover = false
|
||||
|
|
@ -33,14 +33,6 @@ import WebKit
|
|||
}
|
||||
|
||||
func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) {
|
||||
if message.name == WebViewAction.readingProgressUpdate.rawValue {
|
||||
let messageBody = message.body as? [String: Double]
|
||||
|
||||
if let messageBody = messageBody, let progress = messageBody["progress"] {
|
||||
homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress)
|
||||
}
|
||||
}
|
||||
|
||||
if let replyHandler = replyHandler {
|
||||
viewModel.webViewActionWithReplyHandler(
|
||||
message: message,
|
||||
|
|
@ -53,12 +45,6 @@ import WebKit
|
|||
if message.name == WebViewAction.highlightAction.rawValue {
|
||||
handleHighlightAction(message: message)
|
||||
}
|
||||
|
||||
if message.name == WebViewAction.readingProgressUpdate.rawValue {
|
||||
guard let messageBody = message.body as? [String: Double] else { return }
|
||||
guard let progress = messageBody["progress"] else { return }
|
||||
homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHighlightAction(message: WKScriptMessage) {
|
||||
|
|
@ -107,7 +93,7 @@ import WebKit
|
|||
action: {
|
||||
homeFeedViewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
linkId: item.id,
|
||||
objectID: item.objectID,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
},
|
||||
|
|
@ -139,7 +125,7 @@ import WebKit
|
|||
}
|
||||
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id)
|
||||
homeFeedViewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
}
|
||||
Button("Cancel", role: .cancel, action: {})
|
||||
}
|
||||
|
|
@ -149,7 +135,8 @@ import WebKit
|
|||
ZStack {
|
||||
if let articleContent = viewModel.articleContent {
|
||||
WebReader(
|
||||
articleContent: articleContent,
|
||||
htmlContent: articleContent.htmlContent,
|
||||
highlightsJSONString: articleContent.highlightsJSONString,
|
||||
item: item,
|
||||
openLinkAction: {
|
||||
#if os(macOS)
|
||||
|
|
@ -205,7 +192,7 @@ import WebKit
|
|||
.contentShape(Rectangle())
|
||||
.onAppear {
|
||||
if !viewModel.isLoading {
|
||||
viewModel.loadContent(dataService: dataService, slug: item.slug)
|
||||
viewModel.loadContent(dataService: dataService, slug: item.unwrappedSlug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,26 +4,29 @@ import Utils
|
|||
|
||||
struct WebReaderContent {
|
||||
let textFontSize: Int
|
||||
let articleContent: ArticleContent
|
||||
let item: FeedItem
|
||||
let htmlContent: String
|
||||
let highlightsJSONString: String
|
||||
let item: LinkedItem
|
||||
let themeKey: String
|
||||
|
||||
init(
|
||||
articleContent: ArticleContent,
|
||||
item: FeedItem,
|
||||
htmlContent: String,
|
||||
highlightsJSONString: String,
|
||||
item: LinkedItem,
|
||||
isDark: Bool,
|
||||
fontSize: Int
|
||||
) {
|
||||
self.textFontSize = fontSize
|
||||
self.articleContent = articleContent
|
||||
self.htmlContent = htmlContent
|
||||
self.highlightsJSONString = highlightsJSONString
|
||||
self.item = item
|
||||
self.themeKey = isDark ? "Gray" : "LightGray"
|
||||
}
|
||||
|
||||
// swiftlint:disable line_length
|
||||
var styledContent: String {
|
||||
let savedAt = "new Date(\(item.savedAt.timeIntervalSince1970 * 1000)).toISOString()"
|
||||
let createdAt = "new Date(\(item.createdAt.timeIntervalSince1970 * 1000)).toISOString()"
|
||||
let savedAt = "new Date(\(item.unwrappedSavedAt.timeIntervalSince1970 * 1000)).toISOString()"
|
||||
let createdAt = "new Date(\(item.unwrappedCreatedAt.timeIntervalSince1970 * 1000)).toISOString()"
|
||||
let publishedAt = item.publishDate != nil ? "new Date(\(item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" : "undefined"
|
||||
|
||||
return """
|
||||
|
|
@ -39,7 +42,7 @@ struct WebReaderContent {
|
|||
<body>
|
||||
<div id="root" />
|
||||
<div id='_omnivore-htmlContent' style="display: none;">
|
||||
\(articleContent.htmlContent)
|
||||
\(htmlContent)
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
window.omnivoreEnv = {
|
||||
|
|
@ -50,20 +53,20 @@ struct WebReaderContent {
|
|||
}
|
||||
|
||||
window.omnivoreArticle = {
|
||||
id: "\(item.id)",
|
||||
linkId: "\(item.id)",
|
||||
slug: "\(item.slug)",
|
||||
id: "\(item.unwrappedID)",
|
||||
linkId: "\(item.unwrappedID)",
|
||||
slug: "\(item.unwrappedSlug)",
|
||||
createdAt: \(createdAt),
|
||||
savedAt: \(savedAt),
|
||||
publishedAt: \(publishedAt),
|
||||
url: `\(item.pageURLString)`,
|
||||
title: `\(item.title.replacingOccurrences(of: "`", with: "\\`"))`,
|
||||
url: `\(item.unwrappedPageURLString)`,
|
||||
title: `\(item.unwrappedTitle.replacingOccurrences(of: "`", with: "\\`"))`,
|
||||
content: document.getElementById('_omnivore-htmlContent').innerHTML,
|
||||
originalArticleUrl: "\(item.pageURLString)",
|
||||
originalArticleUrl: "\(item.unwrappedPageURLString)",
|
||||
contentReader: "WEB",
|
||||
readingProgressPercent: \(item.readingProgress),
|
||||
readingProgressAnchorIndex: \(item.readingProgressAnchor),
|
||||
highlights: \(articleContent.highlightsJSONString),
|
||||
highlights: \(highlightsJSONString),
|
||||
}
|
||||
|
||||
window.fontSize = \(textFontSize)
|
||||
|
|
|
|||
|
|
@ -9,11 +9,6 @@ struct SafariWebLink: Identifiable {
|
|||
let url: URL
|
||||
}
|
||||
|
||||
func encodeHighlightResult(_ highlight: Highlight) -> [String: Any]? {
|
||||
guard let data = try? JSONEncoder().encode(highlight) else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]
|
||||
}
|
||||
|
||||
final class WebReaderViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var articleContent: ArticleContent?
|
||||
|
|
@ -25,24 +20,22 @@ final class WebReaderViewModel: ObservableObject {
|
|||
self.slug = slug
|
||||
isLoading = true
|
||||
|
||||
guard let viewer = dataService.currentViewer else { return }
|
||||
guard let username = dataService.currentViewer?.username else { return }
|
||||
|
||||
if let content = dataService.pageFromCache(slug: slug) {
|
||||
articleContent = content
|
||||
// continue to load from the web if possible
|
||||
} 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)
|
||||
}
|
||||
|
||||
dataService.articleContentPublisher(username: viewer.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
|
||||
dataService.pageCache.setObject(CachedPageContent(slug, articleContent), forKey: NSString(string: slug))
|
||||
}
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func createHighlight(
|
||||
|
|
@ -50,7 +43,7 @@ final class WebReaderViewModel: ObservableObject {
|
|||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.createHighlightPublisher(
|
||||
let result = dataService.createHighlight(
|
||||
shortId: messageBody["shortId"] as? String ?? "",
|
||||
highlightID: messageBody["id"] as? String ?? "",
|
||||
quote: messageBody["quote"] as? String ?? "",
|
||||
|
|
@ -58,17 +51,8 @@ final class WebReaderViewModel: ObservableObject {
|
|||
articleId: messageBody["articleId"] as? String ?? "",
|
||||
annotation: messageBody["annotation"] as? String ?? ""
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler([], "createHighlight: Error encoding response")
|
||||
} receiveValue: { highlight in
|
||||
if let highlight = encodeHighlightResult(highlight) {
|
||||
replyHandler(["result": highlight], nil)
|
||||
} else {
|
||||
replyHandler([], "createHighlight: Error encoding response")
|
||||
}
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
return replyHandler(["result": result], nil)
|
||||
}
|
||||
|
||||
func deleteHighlight(
|
||||
|
|
@ -76,16 +60,12 @@ final class WebReaderViewModel: ObservableObject {
|
|||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.deleteHighlightPublisher(
|
||||
highlightId: messageBody["highlightId"] as? String ?? ""
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler(["result": false], nil)
|
||||
} receiveValue: { _ in
|
||||
if let highlightID = messageBody["highlightId"] as? String {
|
||||
dataService.deleteHighlight(highlightID: highlightID)
|
||||
replyHandler(["result": true], nil)
|
||||
} else {
|
||||
replyHandler(["result": false], nil)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func mergeHighlight(
|
||||
|
|
@ -93,25 +73,28 @@ final class WebReaderViewModel: ObservableObject {
|
|||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.mergeHighlightPublisher(
|
||||
shortId: messageBody["shortId"] as? String ?? "",
|
||||
highlightID: messageBody["id"] as? String ?? "",
|
||||
quote: messageBody["quote"] as? String ?? "",
|
||||
patch: messageBody["patch"] as? String ?? "",
|
||||
articleId: messageBody["articleId"] as? String ?? "",
|
||||
overlapHighlightIdList: messageBody["overlapHighlightIdList"] as? [String] ?? []
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler([], "mergeHighlight: Error encoding response")
|
||||
} receiveValue: { highlight in
|
||||
if let highlight = encodeHighlightResult(highlight) {
|
||||
replyHandler(["result": highlight], nil)
|
||||
} else {
|
||||
replyHandler([], "mergeHighlight: Error encoding response")
|
||||
}
|
||||
guard
|
||||
let shortId = messageBody["shortId"] as? String,
|
||||
let highlightID = messageBody["id"] as? String,
|
||||
let quote = messageBody["quote"] as? String,
|
||||
let patch = messageBody["patch"] as? String,
|
||||
let articleId = messageBody["articleId"] as? String,
|
||||
let overlapHighlightIdList = messageBody["overlapHighlightIdList"] as? [String]
|
||||
else {
|
||||
replyHandler([], "createHighlight: Error encoding response")
|
||||
return
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
let jsonHighlight = dataService.mergeHighlights(
|
||||
shortId: shortId,
|
||||
highlightID: highlightID,
|
||||
quote: quote,
|
||||
patch: patch,
|
||||
articleId: articleId,
|
||||
overlapHighlightIdList: overlapHighlightIdList
|
||||
)
|
||||
|
||||
replyHandler(["result": jsonHighlight], nil)
|
||||
}
|
||||
|
||||
func updateHighlight(
|
||||
|
|
@ -119,19 +102,15 @@ final class WebReaderViewModel: ObservableObject {
|
|||
replyHandler: @escaping WKScriptMessageReplyHandler,
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.updateHighlightAttributesPublisher(
|
||||
highlightID: messageBody["highlightId"] as? String ?? "",
|
||||
annotation: messageBody["annotation"] as? String ?? "",
|
||||
sharedAt: nil
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
let highlightID = messageBody["highlightId"] as? String
|
||||
let annotation = messageBody["annotation"] as? String
|
||||
|
||||
if let highlightID = highlightID, let annotation = annotation {
|
||||
dataService.updateHighlightAttributes(highlightID: highlightID, annotation: annotation)
|
||||
replyHandler(["result": highlightID], nil)
|
||||
} else {
|
||||
replyHandler([], "updateHighlight: Error encoding response")
|
||||
} receiveValue: { highlight in
|
||||
// Update highlight JS code just expects the highlight ID back
|
||||
replyHandler(["result": highlight.id], nil)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func updateReadingProgress(
|
||||
|
|
@ -148,18 +127,8 @@ final class WebReaderViewModel: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
dataService.updateArticleReadingProgressPublisher(
|
||||
itemID: itemID,
|
||||
readingProgress: readingProgress,
|
||||
anchorIndex: anchorIndex
|
||||
)
|
||||
.sink { completion in
|
||||
guard case .failure = completion else { return }
|
||||
replyHandler(["result": false], nil)
|
||||
} receiveValue: { _ in
|
||||
replyHandler(["result": true], nil)
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
dataService.updateLinkReadingProgress(itemID: itemID, readingProgress: readingProgress, anchorIndex: anchorIndex)
|
||||
replyHandler(["result": true], nil)
|
||||
}
|
||||
|
||||
func webViewActionWithReplyHandler(
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
public class CachedPageContent: NSObject {
|
||||
public let slug: String
|
||||
public let value: ArticleContent
|
||||
|
||||
public init(_ slug: String, _ content: ArticleContent) {
|
||||
self.slug = slug
|
||||
self.value = content
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArticleContent {
|
||||
public let htmlContent: String
|
||||
public let highlights: [Highlight]
|
||||
|
||||
public init(
|
||||
htmlContent: String,
|
||||
highlights: [Highlight]
|
||||
) {
|
||||
self.htmlContent = htmlContent
|
||||
self.highlights = highlights
|
||||
}
|
||||
|
||||
public var highlightsJSONString: String {
|
||||
let jsonData = try? JSONEncoder().encode(highlights)
|
||||
guard let jsonData = jsonData else { return "[]" }
|
||||
return String(data: jsonData, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21A559" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="annotation" optional="YES" attributeType="String"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="createdByMe" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="markedForDeletion" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="patch" attributeType="String"/>
|
||||
<attribute name="prefix" optional="YES" attributeType="String"/>
|
||||
<attribute name="quote" attributeType="String"/>
|
||||
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="shortId" attributeType="String"/>
|
||||
<attribute name="suffix" optional="YES" attributeType="String"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="highlights" inverseEntity="LinkedItem"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="LinkedItem" representedClassName="LinkedItem" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="author" optional="YES" attributeType="String"/>
|
||||
<attribute name="contentReader" optional="YES" attributeType="String"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="descriptionText" optional="YES" attributeType="String"/>
|
||||
<attribute name="htmlContent" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="imageURLString" optional="YES" attributeType="String"/>
|
||||
<attribute name="isArchived" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="onDeviceImageURLString" optional="YES" attributeType="String"/>
|
||||
<attribute name="pageURLString" attributeType="String"/>
|
||||
<attribute name="publishDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="publisherURLString" optional="YES" attributeType="String"/>
|
||||
<attribute name="readingProgress" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="readingProgressAnchor" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="savedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="slug" attributeType="String"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
|
||||
<relationship name="labels" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="LinkedItemLabel" representedClassName="LinkedItemLabel" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="color" attributeType="String"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="labelDescription" optional="YES" attributeType="String"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="NewsletterEmail" representedClassName="NewsletterEmail" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="confirmationCode" optional="YES" attributeType="String"/>
|
||||
<attribute name="email" attributeType="String"/>
|
||||
<attribute name="emailId" attributeType="String"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="emailId"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Viewer" representedClassName="Viewer" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="profileImageURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="userID" attributeType="String"/>
|
||||
<attribute name="username" attributeType="String"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="userID"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Highlight" positionX="27" positionY="225" width="128" height="224"/>
|
||||
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="329"/>
|
||||
<element name="LinkedItemLabel" positionX="-36" positionY="18" width="128" height="119"/>
|
||||
<element name="NewsletterEmail" positionX="0" positionY="180" width="128" height="74"/>
|
||||
<element name="Viewer" positionX="45" positionY="234" width="128" height="89"/>
|
||||
</elements>
|
||||
</model>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
let logger = Logger(subsystem: "app.omnivore", category: "models")
|
||||
|
||||
/// An `NSPersistentContainer` subclass that lives in the `Models` package so that
|
||||
/// the data model is looked for in the same package bundle (rather than the main bundle)
|
||||
public class PersistentContainer: NSPersistentContainer {
|
||||
public static func make() -> PersistentContainer {
|
||||
let modelURL = Bundle.module.url(forResource: "CoreDataModel", withExtension: "momd")!
|
||||
let model = NSManagedObjectModel(contentsOf: modelURL)!
|
||||
let container = PersistentContainer(name: "DataModel", managedObjectModel: model)
|
||||
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.name = "viewContext"
|
||||
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
|
||||
return container
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import Foundation
|
||||
|
||||
public struct ArticleContent {
|
||||
public let htmlContent: String
|
||||
public let highlightsJSONString: String
|
||||
|
||||
public init(
|
||||
htmlContent: String,
|
||||
highlightsJSONString: String
|
||||
) {
|
||||
self.htmlContent = htmlContent
|
||||
self.highlightsJSONString = highlightsJSONString
|
||||
}
|
||||
}
|
||||
120
apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift
Normal file
120
apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public struct HomeFeedData { // TODO: rename this
|
||||
public let items: [NSManagedObjectID]
|
||||
public let cursor: String?
|
||||
|
||||
public init(items: [NSManagedObjectID], cursor: String?) {
|
||||
self.items = items
|
||||
self.cursor = cursor
|
||||
}
|
||||
}
|
||||
|
||||
// Internal model used for parsing a push notification object only
|
||||
public struct JSONArticle: Decodable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let createdAt: Date
|
||||
public let savedAt: Date
|
||||
public let image: String
|
||||
public let readingProgressPercent: Double
|
||||
public let readingProgressAnchorIndex: Int
|
||||
public let slug: String
|
||||
public let contentReader: String
|
||||
public let url: String
|
||||
public let isArchived: Bool
|
||||
}
|
||||
|
||||
public extension LinkedItem {
|
||||
var unwrappedID: String { id ?? "" }
|
||||
var unwrappedSlug: String { slug ?? "" }
|
||||
var unwrappedTitle: String { title ?? "" }
|
||||
var unwrappedPageURLString: String { pageURLString ?? "" }
|
||||
var unwrappedSavedAt: Date { savedAt ?? Date() }
|
||||
var unwrappedCreatedAt: Date { createdAt ?? Date() }
|
||||
|
||||
var isRead: Bool {
|
||||
readingProgress >= 0.98
|
||||
}
|
||||
|
||||
var isPDF: Bool {
|
||||
if let contentReader = contentReader {
|
||||
return contentReader == "PDF"
|
||||
}
|
||||
return (pageURLString ?? "").hasSuffix("pdf")
|
||||
}
|
||||
|
||||
var publisherHostname: String? {
|
||||
URL(string: publisherURLString ?? pageURLString ?? "")?.host
|
||||
}
|
||||
|
||||
var imageURL: URL? {
|
||||
imageURLString.flatMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
var pdfURL: URL? {
|
||||
guard isPDF else { return nil }
|
||||
return URL(string: pageURLString ?? "")
|
||||
}
|
||||
|
||||
static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LinkedItem? {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "id == %@", itemID
|
||||
)
|
||||
|
||||
var item: LinkedItem?
|
||||
|
||||
context.performAndWait {
|
||||
item = (try? context.fetch(fetchRequest))?.first
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func update(
|
||||
inContext context: NSManagedObjectContext,
|
||||
newReadingProgress: Double? = nil,
|
||||
newAnchorIndex: Int? = nil,
|
||||
newIsArchivedValue: Bool? = nil
|
||||
) {
|
||||
context.perform {
|
||||
if let newReadingProgress = newReadingProgress {
|
||||
self.readingProgress = newReadingProgress
|
||||
}
|
||||
|
||||
if let newAnchorIndex = newAnchorIndex {
|
||||
self.readingProgressAnchor = Int64(newAnchorIndex)
|
||||
}
|
||||
|
||||
if let newIsArchivedValue = newIsArchivedValue {
|
||||
self.isArchived = newIsArchivedValue
|
||||
}
|
||||
|
||||
guard context.hasChanges else { return }
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem updated succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to update LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remove(inContext context: NSManagedObjectContext) {
|
||||
context.perform {
|
||||
context.delete(self)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem removed")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to remove LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
apple/OmnivoreKit/Sources/Models/DataModels/Highlight.swift
Normal file
54
apple/OmnivoreKit/Sources/Models/DataModels/Highlight.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public extension Highlight {
|
||||
var unwrappedID: String { id ?? "" }
|
||||
|
||||
static func lookup(byID highlightID: String, inContext context: NSManagedObjectContext) -> Highlight? {
|
||||
let fetchRequest: NSFetchRequest<Models.Highlight> = Highlight.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "id == %@", highlightID
|
||||
)
|
||||
|
||||
var highlight: Highlight?
|
||||
|
||||
context.performAndWait {
|
||||
highlight = (try? context.fetch(fetchRequest))?.first
|
||||
}
|
||||
|
||||
return highlight
|
||||
}
|
||||
|
||||
func update(
|
||||
inContext context: NSManagedObjectContext,
|
||||
newAnnotation: String
|
||||
) {
|
||||
context.perform {
|
||||
self.annotation = newAnnotation
|
||||
|
||||
guard context.hasChanges else { return }
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Highlight updated succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to update Highlight: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remove(inContext context: NSManagedObjectContext) {
|
||||
context.perform {
|
||||
context.delete(self)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Highlight removed")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to remove Highlight: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import Foundation
|
||||
|
||||
public extension NewsletterEmail {
|
||||
var unwrappedEmailId: String {
|
||||
emailId ?? ""
|
||||
}
|
||||
|
||||
var unwrappedEmail: String {
|
||||
email ?? ""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import Foundation
|
||||
|
||||
public enum ServerSyncStatus: Int {
|
||||
case isNSync
|
||||
case isSyncing
|
||||
case needsDeletion
|
||||
case needsCreation
|
||||
case needsUpdate
|
||||
}
|
||||
|
|
@ -45,21 +45,12 @@ public extension UserProfile {
|
|||
}
|
||||
}
|
||||
|
||||
public struct Viewer {
|
||||
public let userID: String
|
||||
public let username: String
|
||||
public let name: String
|
||||
public let profileImageURL: String?
|
||||
public extension Viewer {
|
||||
var unwrappedUsername: String {
|
||||
username ?? ""
|
||||
}
|
||||
|
||||
public init(
|
||||
username: String,
|
||||
name: String,
|
||||
profileImageURL: String?,
|
||||
userID: String
|
||||
) {
|
||||
self.username = username
|
||||
self.name = name
|
||||
self.profileImageURL = profileImageURL
|
||||
self.userID = userID
|
||||
var unwrappedName: String {
|
||||
name ?? ""
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
public struct HomeFeedData {
|
||||
public let items: [FeedItem]
|
||||
public let cursor: String?
|
||||
|
||||
public init(items: [FeedItem], cursor: String?) {
|
||||
self.items = items
|
||||
self.cursor = cursor
|
||||
}
|
||||
}
|
||||
|
||||
public struct FeedItem: Identifiable, Hashable, Decodable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let createdAt: Date
|
||||
public let savedAt: Date
|
||||
public var readingProgress: Double
|
||||
public var readingProgressAnchor: Int
|
||||
public let imageURLString: String?
|
||||
public let onDeviceImageURLString: String?
|
||||
public let documentDirectoryPath: String?
|
||||
public let pageURLString: String
|
||||
public let description: String?
|
||||
public let publisherURLString: String?
|
||||
public let author: String?
|
||||
public let publishDate: Date?
|
||||
public let slug: String
|
||||
public let isArchived: Bool
|
||||
public let contentReader: String?
|
||||
public var labels: [FeedItemLabel]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
createdAt: Date,
|
||||
savedAt: Date,
|
||||
readingProgress: Double,
|
||||
readingProgressAnchor: Int,
|
||||
imageURLString: String?,
|
||||
onDeviceImageURLString: String?,
|
||||
documentDirectoryPath: String?,
|
||||
pageURLString: String,
|
||||
description: String?,
|
||||
publisherURLString: String?,
|
||||
author: String?,
|
||||
publishDate: Date?,
|
||||
slug: String,
|
||||
isArchived: Bool,
|
||||
contentReader: String?,
|
||||
labels: [FeedItemLabel]
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.createdAt = createdAt
|
||||
self.savedAt = savedAt
|
||||
self.readingProgress = readingProgress
|
||||
self.readingProgressAnchor = readingProgressAnchor
|
||||
self.imageURLString = imageURLString
|
||||
self.onDeviceImageURLString = onDeviceImageURLString
|
||||
self.documentDirectoryPath = documentDirectoryPath
|
||||
self.pageURLString = pageURLString
|
||||
self.description = description
|
||||
self.publisherURLString = publisherURLString
|
||||
self.author = author
|
||||
self.publishDate = publishDate
|
||||
self.slug = slug
|
||||
self.isArchived = isArchived
|
||||
self.contentReader = contentReader
|
||||
self.labels = labels
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
// swiftlint:disable:next line_length
|
||||
case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url, labels
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
savedAt = try container.decode(Date.self, forKey: .savedAt)
|
||||
description = try container.decode(String?.self, forKey: .title)
|
||||
imageURLString = try container.decode(String?.self, forKey: .image)
|
||||
readingProgress = try container.decode(Double.self, forKey: .readingProgressPercent)
|
||||
readingProgressAnchor = try container.decode(Int.self, forKey: .readingProgressAnchorIndex)
|
||||
slug = try container.decode(String.self, forKey: .slug)
|
||||
contentReader = try container.decode(String.self, forKey: .contentReader)
|
||||
pageURLString = try container.decode(String.self, forKey: .url)
|
||||
isArchived = try container.decode(Bool.self, forKey: .isArchived)
|
||||
labels = try container.decode([FeedItemLabel].self, forKey: .labels)
|
||||
|
||||
self.onDeviceImageURLString = nil
|
||||
self.documentDirectoryPath = nil
|
||||
self.publisherURLString = nil
|
||||
self.author = nil
|
||||
self.publishDate = nil
|
||||
}
|
||||
|
||||
public static func fromJsonArticle(linkData: Data) -> FeedItem? {
|
||||
if let item = try? JSONDecoder().decode(FeedItem.self, from: linkData) {
|
||||
return item
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var isRead: Bool {
|
||||
readingProgress >= 0.98
|
||||
}
|
||||
|
||||
public var isPDF: Bool {
|
||||
if let contentReader = contentReader {
|
||||
return contentReader == "PDF"
|
||||
}
|
||||
return pageURLString.hasSuffix("pdf")
|
||||
}
|
||||
|
||||
public var publisherHostname: String? {
|
||||
URL(string: publisherURLString ?? pageURLString)?.host
|
||||
}
|
||||
|
||||
public var imageURL: URL? {
|
||||
imageURLString.flatMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
public var pdfURL: URL? {
|
||||
guard isPDF else { return nil }
|
||||
let documentDirectoryURL = documentDirectoryPath.flatMap { URL(string: $0) }
|
||||
return documentDirectoryURL ?? URL(string: pageURLString)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
public struct FeedItemLabel: Decodable, Hashable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let color: String
|
||||
public let createdAt: Date?
|
||||
public let description: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
color: String,
|
||||
createdAt: Date?,
|
||||
description: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.createdAt = createdAt
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
public struct Highlight: Identifiable, Hashable, Codable {
|
||||
public let id: String
|
||||
public let shortId: String
|
||||
public let quote: String
|
||||
public let prefix: String?
|
||||
public let suffix: String?
|
||||
public let patch: String
|
||||
public let annotation: String?
|
||||
public let createdAt: Date?
|
||||
public let updatedAt: Date?
|
||||
public let createdByMe: Bool
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
shortId: String,
|
||||
quote: String,
|
||||
prefix: String?,
|
||||
suffix: String?,
|
||||
patch: String,
|
||||
annotation: String?,
|
||||
createdByMe: Bool,
|
||||
createdAt: Date? = nil,
|
||||
updatedAt: Date? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.shortId = shortId
|
||||
self.quote = quote
|
||||
self.prefix = prefix
|
||||
self.suffix = suffix
|
||||
self.patch = patch
|
||||
self.annotation = annotation
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.createdByMe = createdByMe
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
public struct NewsletterEmail: Identifiable {
|
||||
public let id = UUID()
|
||||
public let emailId: String
|
||||
public let email: String
|
||||
public let confirmationCode: String?
|
||||
|
||||
public init(emailId: String, email: String, confirmationCode: String?) {
|
||||
self.emailId = emailId
|
||||
self.email = email
|
||||
self.confirmationCode = confirmationCode
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +1,44 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import OSLog
|
||||
|
||||
public class CacheManager: NSObject, NSCacheDelegate {
|
||||
public func cache(_: NSCache<AnyObject, AnyObject>, willEvictObject obj: Any) {
|
||||
// This is just used for debugging
|
||||
if let content = obj as? CachedPageContent {
|
||||
print("evicting page from cache", content.slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
let logger = Logger(subsystem: "app.omnivore", category: "data-service")
|
||||
|
||||
public final class DataService: ObservableObject {
|
||||
public static var registerIntercomUser: ((String) -> Void)?
|
||||
public static var showIntercomMessenger: (() -> Void)?
|
||||
|
||||
public let appEnvironment: AppEnvironment
|
||||
public internal(set) var currentViewer: Viewer?
|
||||
let networker: Networker
|
||||
|
||||
public let pageCache = NSCache<NSString, CachedPageContent>()
|
||||
let pageCacheQueue = DispatchQueue.global(qos: .background)
|
||||
|
||||
let highlightsCache = NSCache<AnyObject, CachedPDFHighlights>()
|
||||
let highlightsCacheQueue = DispatchQueue(label: "app.omnivore.highlights.cache.queue", attributes: .concurrent)
|
||||
|
||||
let cacheManager: CacheManager
|
||||
let persistentContainer: PersistentContainer
|
||||
let backgroundContext: NSManagedObjectContext
|
||||
var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
public var viewContext: NSManagedObjectContext {
|
||||
persistentContainer.viewContext
|
||||
}
|
||||
|
||||
public init(appEnvironment: AppEnvironment, networker: Networker) {
|
||||
self.appEnvironment = appEnvironment
|
||||
self.networker = networker
|
||||
self.cacheManager = CacheManager()
|
||||
pageCache.delegate = cacheManager
|
||||
self.persistentContainer = PersistentContainer.make()
|
||||
self.backgroundContext = persistentContainer.newBackgroundContext()
|
||||
backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
|
||||
persistentContainer.loadPersistentStores { _, error in
|
||||
if let error = error {
|
||||
fatalError("Core Data store failed to load with error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func clearHighlights() {
|
||||
highlightsCache.removeAllObjects()
|
||||
public var currentViewer: Viewer? {
|
||||
let fetchRequest: NSFetchRequest<Models.Viewer> = Viewer.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1 // we should only have one viewer saved
|
||||
return try? persistentContainer.viewContext.fetch(fetchRequest).first
|
||||
}
|
||||
|
||||
public func switchAppEnvironment(appEnvironment: AppEnvironment) {
|
||||
|
|
@ -50,35 +52,37 @@ public final class DataService: ObservableObject {
|
|||
}
|
||||
|
||||
public extension DataService {
|
||||
func prefetchPages(items: [FeedItem]) {
|
||||
print("prefetching pages")
|
||||
guard let viewer = currentViewer else { return }
|
||||
func prefetchPages(itemSlugs: [String]) {
|
||||
guard let username = currentViewer?.username else { return }
|
||||
|
||||
for item in items {
|
||||
let slug = item.slug
|
||||
articleContentPublisher(username: viewer.username, slug: slug).sink(
|
||||
for slug in itemSlugs {
|
||||
articleContentPublisher(username: username, slug: slug).sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { [weak self] articleContent in
|
||||
self?.pageCache.setObject(CachedPageContent(slug, articleContent), forKey: NSString(string: slug))
|
||||
}
|
||||
receiveValue: { _ in }
|
||||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func pageFromCache(slug: String) -> ArticleContent? {
|
||||
if let content = pageCache.object(forKey: NSString(string: slug)) {
|
||||
print("cache hit", slug)
|
||||
return content.value
|
||||
} else {
|
||||
print("cache miss", slug)
|
||||
}
|
||||
return nil
|
||||
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?) {
|
||||
if let slug = slug {
|
||||
pageCache.removeObject(forKey: NSString(string: slug))
|
||||
}
|
||||
}
|
||||
func invalidateCachedPage(slug _: String?) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
public enum CoreDataError: Error {
|
||||
case general
|
||||
}
|
||||
|
||||
public enum ServerError: String, Error {
|
||||
case noConnection
|
||||
case unauthenticated
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func archiveLinkPublisher(
|
||||
itemID: String,
|
||||
archived: Bool
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
extension DataService {
|
||||
public func archiveLink(objectID: NSManagedObjectID, archived: Bool) {
|
||||
// Update CoreData
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = self.backgroundContext.object(with: objectID) as? LinkedItem else { return }
|
||||
linkedItem.update(inContext: self.backgroundContext, newIsArchivedValue: archived)
|
||||
|
||||
// Send update to server
|
||||
self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, objectID: objectID, archived: archived)
|
||||
}
|
||||
}
|
||||
|
||||
func syncLinkArchiveStatus(itemID: String, objectID: NSManagedObjectID, archived: Bool) {
|
||||
enum MutationResult {
|
||||
case success(linkId: String)
|
||||
case error(errorCode: Enums.ArchiveLinkErrorCode)
|
||||
|
|
@ -32,29 +41,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 payload.errors != nil {
|
||||
promise(.failure(.message(messageText: "Error archiving link")))
|
||||
}
|
||||
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 .success(linkId):
|
||||
promise(.success(linkId))
|
||||
case .error:
|
||||
promise(.failure(.message(messageText: "Error archiving link")))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "Error archiving link")))
|
||||
}
|
||||
context.perform {
|
||||
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem updated succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to update LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,40 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func createHighlightPublisher(
|
||||
extension DataService {
|
||||
public func createHighlight(
|
||||
shortId: String,
|
||||
highlightID: String,
|
||||
quote: String,
|
||||
patch: String,
|
||||
articleId: String,
|
||||
annotation: String? = nil
|
||||
) -> AnyPublisher<Highlight, BasicError> {
|
||||
) -> [String: Any]? {
|
||||
let internalHighlight = InternalHighlight(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
quote: quote,
|
||||
prefix: nil, suffix: nil,
|
||||
patch: patch,
|
||||
annotation: annotation,
|
||||
createdAt: nil,
|
||||
updatedAt: nil,
|
||||
createdByMe: true
|
||||
)
|
||||
|
||||
internalHighlight.persist(context: backgroundContext, associatedItemID: articleId)
|
||||
|
||||
// Send update to server
|
||||
syncHighlightCreation(highlight: internalHighlight, articleId: articleId)
|
||||
|
||||
return internalHighlight.encoded()
|
||||
}
|
||||
|
||||
func syncHighlightCreation(highlight: InternalHighlight, articleId: String) {
|
||||
enum MutationResult {
|
||||
case saved(highlight: Highlight)
|
||||
case saved(highlight: InternalHighlight)
|
||||
case error(errorCode: Enums.CreateHighlightErrorCode)
|
||||
}
|
||||
|
||||
|
|
@ -29,12 +50,12 @@ public extension DataService {
|
|||
let mutation = Selection.Mutation {
|
||||
try $0.createHighlight(
|
||||
input: InputObjects.CreateHighlightInput(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
id: highlight.id,
|
||||
shortId: highlight.shortId,
|
||||
articleId: articleId,
|
||||
patch: patch,
|
||||
quote: quote,
|
||||
annotation: OptionalArgument(annotation)
|
||||
patch: highlight.patch,
|
||||
quote: highlight.quote,
|
||||
annotation: OptionalArgument(highlight.annotation)
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
@ -42,29 +63,27 @@ 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 data = try? result.get()
|
||||
let syncStatus: ServerSyncStatus = data == nil ? .needsCreation : .isNSync
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(highlight: highlight):
|
||||
promise(.success(highlight))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
context.perform {
|
||||
let fetchRequest: NSFetchRequest<Models.Highlight> = Highlight.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", highlight.id)
|
||||
|
||||
guard let highlightObject = (try? context.fetch(fetchRequest))?.first else { return }
|
||||
highlightObject.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Highlight created succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to create Highlight: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
|
@ -8,9 +9,9 @@ public extension DataService {
|
|||
name: String,
|
||||
color: String,
|
||||
description: String?
|
||||
) -> AnyPublisher<FeedItemLabel, BasicError> {
|
||||
) -> AnyPublisher<NSManagedObjectID, BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(label: FeedItemLabel)
|
||||
case saved(label: InternalLinkedItemLabel)
|
||||
case error(errorCode: Enums.CreateLabelErrorCode)
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +47,11 @@ public extension DataService {
|
|||
|
||||
switch payload.data {
|
||||
case let .saved(label: label):
|
||||
promise(.success(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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func createNewsletterEmailPublisher() -> AnyPublisher<NewsletterEmail, BasicError> {
|
||||
func createNewsletterEmailPublisher() -> AnyPublisher<NSManagedObjectID, BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(newsletterEmail: NewsletterEmail)
|
||||
case saved(newsletterEmail: InternalNewsletterEmail)
|
||||
case error(errorCode: Enums.CreateNewsletterEmailErrorCode)
|
||||
}
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ public extension DataService {
|
|||
try $0.on(
|
||||
createNewsletterEmailSuccess: .init {
|
||||
.saved(newsletterEmail: try $0.newsletterEmail(selection: Selection.NewsletterEmail {
|
||||
NewsletterEmail(
|
||||
InternalNewsletterEmail(
|
||||
emailId: try $0.id(),
|
||||
email: try $0.address(),
|
||||
confirmationCode: try $0.confirmationCode()
|
||||
|
|
@ -44,7 +45,11 @@ public extension DataService {
|
|||
|
||||
switch payload.data {
|
||||
case let .saved(newsletterEmail: newsletterEmail):
|
||||
promise(.success(newsletterEmail))
|
||||
if let newsletterEmailObjectID = newsletterEmail.persist(context: self.backgroundContext) {
|
||||
promise(.success(newsletterEmailObjectID))
|
||||
} else {
|
||||
promise(.failure(.message(messageText: "coredata error")))
|
||||
}
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,28 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func deleteHighlightPublisher(
|
||||
highlightId: String
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
func deleteHighlight(highlightID: String) {
|
||||
if let highlight = Highlight.lookup(byID: highlightID, inContext: backgroundContext) {
|
||||
deleteHighlight(objectID: highlight.objectID)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteHighlight(objectID: NSManagedObjectID) {
|
||||
// Update CoreData
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let highlight = self.backgroundContext.object(with: objectID) as? Highlight else { return }
|
||||
highlight.remove(inContext: self.backgroundContext)
|
||||
|
||||
// Send update to server
|
||||
self.syncHighlightDeletion(highlightID: highlight.unwrappedID, objectID: objectID)
|
||||
}
|
||||
}
|
||||
|
||||
internal func syncHighlightDeletion(highlightID: String, objectID: NSManagedObjectID) {
|
||||
enum MutationResult {
|
||||
case saved(id: String)
|
||||
case error(errorCode: Enums.DeleteHighlightErrorCode)
|
||||
|
|
@ -23,36 +39,36 @@ public extension DataService {
|
|||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.deleteHighlight(
|
||||
highlightId: highlightId.lowercased(),
|
||||
highlightId: highlightID,
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
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 data = try? result.get()
|
||||
let isSyncSuccess = data != nil
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(id: id):
|
||||
promise(.success(id))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
context.perform {
|
||||
guard let highlight = context.object(with: objectID) as? Highlight else { return }
|
||||
|
||||
if isSyncSuccess {
|
||||
highlight.remove(inContext: context)
|
||||
} else {
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Highlight deleted succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to delete Highlight: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,46 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
extension DataService {
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func mergeHighlightPublisher(
|
||||
public func mergeHighlights(
|
||||
shortId: String,
|
||||
highlightID: String,
|
||||
quote: String,
|
||||
patch: String,
|
||||
articleId: String,
|
||||
overlapHighlightIdList: [String]
|
||||
) -> AnyPublisher<Highlight, BasicError> {
|
||||
overlapHighlightIdList: [String] // TODO: pass in annotation?
|
||||
) -> [String: Any]? {
|
||||
let internalHighlight = InternalHighlight(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
quote: quote,
|
||||
prefix: nil,
|
||||
suffix: nil,
|
||||
patch: patch,
|
||||
annotation: nil,
|
||||
createdAt: nil,
|
||||
updatedAt: nil,
|
||||
createdByMe: true
|
||||
)
|
||||
|
||||
internalHighlight.persist(
|
||||
context: backgroundContext,
|
||||
associatedItemID: articleId,
|
||||
oldHighlightsIds: overlapHighlightIdList
|
||||
)
|
||||
|
||||
// Send update to server
|
||||
syncHighlightCreation(highlight: internalHighlight, articleId: articleId)
|
||||
|
||||
return internalHighlight.encoded()
|
||||
}
|
||||
|
||||
func syncHighlightMerge(highlight: InternalHighlight, articleId: String, overlapHighlightIdList: [String]) {
|
||||
enum MutationResult {
|
||||
case saved(highlight: Highlight)
|
||||
case saved(highlight: InternalHighlight)
|
||||
case error(errorCode: Enums.MergeHighlightErrorCode)
|
||||
}
|
||||
|
||||
|
|
@ -30,11 +56,11 @@ public extension DataService {
|
|||
let mutation = Selection.Mutation {
|
||||
try $0.mergeHighlight(
|
||||
input: InputObjects.MergeHighlightInput(
|
||||
id: highlightID,
|
||||
shortId: shortId,
|
||||
id: highlight.id,
|
||||
shortId: highlight.shortId,
|
||||
articleId: articleId,
|
||||
patch: patch,
|
||||
quote: quote,
|
||||
patch: highlight.patch,
|
||||
quote: highlight.quote,
|
||||
prefix: .absent(),
|
||||
suffix: .absent(),
|
||||
annotation: .absent(),
|
||||
|
|
@ -46,29 +72,41 @@ 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 data = try? result.get()
|
||||
let isSyncSuccess = data != nil
|
||||
|
||||
switch payload.data {
|
||||
case let .saved(highlight: highlight):
|
||||
promise(.success(highlight))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
context.perform {
|
||||
let fetchRequest: NSFetchRequest<Models.Highlight> = Highlight.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", highlight.id)
|
||||
|
||||
guard let highlightObject = (try? context.fetch(fetchRequest))?.first else { return }
|
||||
let newHighlightSyncStatus: ServerSyncStatus = data == nil ? .needsCreation : .isNSync
|
||||
highlightObject.serverSyncStatus = Int64(newHighlightSyncStatus.rawValue)
|
||||
|
||||
for overlapHighlightID in overlapHighlightIdList {
|
||||
let fetchRequest: NSFetchRequest<Models.Highlight> = Highlight.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", overlapHighlightID)
|
||||
|
||||
if let highlightObject = (try? context.fetch(fetchRequest))?.first {
|
||||
if isSyncSuccess {
|
||||
highlightObject.remove(inContext: context)
|
||||
} else {
|
||||
highlightObject.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Highlight merged succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to create Highlight: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ public extension DataService {
|
|||
|
||||
switch payload.data {
|
||||
case .success:
|
||||
promise(.success(true))
|
||||
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")))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
let setBookmarkedArticleSelection = Selection.Article {
|
||||
try $0.id()
|
||||
}
|
||||
extension DataService {
|
||||
public func removeLink(objectID: NSManagedObjectID) {
|
||||
// Update CoreData
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = self.backgroundContext.object(with: objectID) as? LinkedItem else { return }
|
||||
linkedItem.remove(inContext: self.backgroundContext)
|
||||
|
||||
public extension DataService {
|
||||
func removeLinkPublisher(
|
||||
itemID: String
|
||||
) -> AnyPublisher<String, BasicError> {
|
||||
// Send update to server
|
||||
self.syncLinkDeletion(itemID: linkedItem.unwrappedID, objectID: objectID)
|
||||
}
|
||||
}
|
||||
|
||||
func syncLinkDeletion(itemID: String, objectID: NSManagedObjectID) {
|
||||
enum MutationResult {
|
||||
case success(linkId: String)
|
||||
case error(errorCode: Enums.SetBookmarkArticleErrorCode)
|
||||
|
|
@ -20,7 +26,9 @@ public extension DataService {
|
|||
try $0.on(
|
||||
setBookmarkArticleSuccess: .init {
|
||||
.success(
|
||||
linkId: try $0.bookmarkedArticle(selection: setBookmarkedArticleSelection)
|
||||
linkId: try $0.bookmarkedArticle(selection: Selection.Article {
|
||||
try $0.id()
|
||||
})
|
||||
)
|
||||
},
|
||||
setBookmarkArticleError: .init { .error(errorCode: try $0.errorCodes().first ?? .notFound) }
|
||||
|
|
@ -39,29 +47,29 @@ 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 link")))
|
||||
}
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
let data = try? result.get()
|
||||
let isSyncSuccess = data != nil
|
||||
|
||||
switch payload.data {
|
||||
case let .success(item):
|
||||
promise(.success(item))
|
||||
case .error(errorCode: _):
|
||||
promise(.failure(.message(messageText: "Error removing link")))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "Error removing link")))
|
||||
}
|
||||
context.perform {
|
||||
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
|
||||
|
||||
if isSyncSuccess {
|
||||
linkedItem.remove(inContext: context)
|
||||
} else {
|
||||
linkedItem.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,12 +1,17 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func updateArticleLabelsPublisher(itemID: String, labelIDs: [String]) -> AnyPublisher<[FeedItemLabel], BasicError> {
|
||||
// swiftlint:disable:next function_body_length
|
||||
func updateArticleLabelsPublisher(
|
||||
itemID: String,
|
||||
labelIDs: [String]
|
||||
) -> AnyPublisher<[NSManagedObjectID], BasicError> {
|
||||
enum MutationResult {
|
||||
case saved(feedItem: [FeedItemLabel])
|
||||
case saved(feedItem: [InternalLinkedItemLabel])
|
||||
case error(errorCode: Enums.SetLabelsErrorCode)
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +46,32 @@ public extension DataService {
|
|||
|
||||
switch payload.data {
|
||||
case let .saved(labels):
|
||||
promise(.success(labels))
|
||||
self.backgroundContext.perform {
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else {
|
||||
promise(.failure(.message(messageText: "failed to set labels")))
|
||||
return
|
||||
}
|
||||
|
||||
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")))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func updateArticleReadingProgressPublisher(
|
||||
itemID: String,
|
||||
readingProgress: Double,
|
||||
anchorIndex: Int
|
||||
) -> AnyPublisher<FeedItem, SaveArticleError> {
|
||||
extension DataService {
|
||||
public func updateLinkReadingProgress(itemID: String, readingProgress: Double, anchorIndex: Int) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
linkedItem.update(
|
||||
inContext: self.backgroundContext,
|
||||
newReadingProgress: readingProgress,
|
||||
newAnchorIndex: anchorIndex
|
||||
)
|
||||
|
||||
// Send update to server
|
||||
self.syncLinkReadingProgress(
|
||||
itemID: linkedItem.unwrappedID,
|
||||
objectID: linkedItem.objectID,
|
||||
readingProgress: readingProgress,
|
||||
anchorIndex: anchorIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func syncLinkReadingProgress(itemID: String, objectID: NSManagedObjectID, readingProgress: Double, anchorIndex: Int) {
|
||||
enum MutationResult {
|
||||
case saved(feedItem: FeedItem)
|
||||
case saved(readingProgress: Double)
|
||||
case error(errorCode: Enums.SaveArticleReadingProgressErrorCode)
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +35,7 @@ public extension DataService {
|
|||
try $0.on(
|
||||
saveArticleReadingProgressSuccess: .init {
|
||||
.saved(
|
||||
feedItem: try $0.updatedArticle(selection: homeFeedItemSelection)
|
||||
readingProgress: try $0.updatedArticle(selection: Selection.Article { try $0.readingProgressPercent() })
|
||||
)
|
||||
},
|
||||
saveArticleReadingProgressError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
|
||||
|
|
@ -38,34 +55,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(.unknown(description: 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(feedItem):
|
||||
promise(.success(feedItem))
|
||||
case let .error(errorCode: errorCode):
|
||||
switch errorCode {
|
||||
case .unauthorized:
|
||||
promise(.failure(.unauthorized))
|
||||
case .badData, .notFound:
|
||||
promise(.failure(.badData))
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.badData))
|
||||
}
|
||||
context.perform {
|
||||
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem updated succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to update LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,31 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func updateHighlightAttributesPublisher(
|
||||
extension DataService {
|
||||
public func updateHighlightAttributes(
|
||||
highlightID: String,
|
||||
annotation: String?,
|
||||
sharedAt: Date?
|
||||
) -> AnyPublisher<Highlight, BasicError> {
|
||||
annotation: String
|
||||
) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let highlight = Highlight.lookup(byID: highlightID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
highlight.update(inContext: self.backgroundContext, newAnnotation: annotation)
|
||||
|
||||
// Send update to server
|
||||
self.syncHighlightAttributes(
|
||||
highlightID: highlightID,
|
||||
objectID: highlight.objectID,
|
||||
annotation: annotation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func syncHighlightAttributes(highlightID: String, objectID: NSManagedObjectID, annotation: String) {
|
||||
enum MutationResult {
|
||||
case saved(highlight: Highlight)
|
||||
case saved(highlight: InternalHighlight)
|
||||
case error(errorCode: Enums.UpdateHighlightErrorCode)
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +43,7 @@ public extension DataService {
|
|||
input: InputObjects.UpdateHighlightInput(
|
||||
highlightId: highlightID,
|
||||
annotation: OptionalArgument(annotation),
|
||||
sharedAt: OptionalArgument(sharedAt.flatMap { DateTime(from: $0) })
|
||||
sharedAt: OptionalArgument(nil)
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
@ -36,29 +51,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: "graphql error: \(graphqlError)")))
|
||||
}
|
||||
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(highlight: highlight):
|
||||
promise(.success(highlight))
|
||||
case let .error(errorCode: errorCode):
|
||||
promise(.failure(.message(messageText: errorCode.rawValue)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "graphql error")))
|
||||
}
|
||||
context.perform {
|
||||
guard let highlight = context.object(with: objectID) as? Highlight else { return }
|
||||
highlight.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Highlight updated succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to update Highlight: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
extension DataService {
|
||||
func syncOfflineItemsWithServerIfNeeded() async throws {
|
||||
// TODO: send a simple request to see if we're online?
|
||||
var unsyncedLinkedItems = [LinkedItem]()
|
||||
var unsyncedHighlights = [Highlight]()
|
||||
|
||||
// LinkedItems
|
||||
let itemsFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
itemsFetchRequest.predicate = NSPredicate(
|
||||
format: "serverSyncStatus != %i", Int64(ServerSyncStatus.isNSync.rawValue)
|
||||
)
|
||||
|
||||
// Highlights
|
||||
let highlightsFetchRequest: NSFetchRequest<Models.Highlight> = Highlight.fetchRequest()
|
||||
highlightsFetchRequest.predicate = NSPredicate(
|
||||
format: "serverSyncStatus != %i", Int64(ServerSyncStatus.isNSync.rawValue)
|
||||
)
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
unsyncedLinkedItems = try itemsFetchRequest.execute()
|
||||
unsyncedHighlights = try highlightsFetchRequest.execute()
|
||||
} catch {
|
||||
throw CoreDataError.general
|
||||
}
|
||||
|
||||
self.syncLinkedItems(unsyncedLinkedItems: unsyncedLinkedItems)
|
||||
self.syncHighlights(unsyncedHighlights: unsyncedHighlights)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncLinkedItems(unsyncedLinkedItems: [LinkedItem]) {
|
||||
for item in unsyncedLinkedItems {
|
||||
guard let syncStatus = ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) else { continue }
|
||||
|
||||
switch syncStatus {
|
||||
case .isNSync, .isSyncing, .needsCreation:
|
||||
break
|
||||
case .needsDeletion:
|
||||
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncLinkDeletion(itemID: item.unwrappedID, objectID: item.objectID)
|
||||
case .needsUpdate:
|
||||
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncLinkArchiveStatus(itemID: item.unwrappedID, objectID: item.objectID, archived: item.isArchived)
|
||||
syncLinkReadingProgress(
|
||||
itemID: item.unwrappedID,
|
||||
objectID: item.objectID,
|
||||
readingProgress: item.readingProgress,
|
||||
anchorIndex: Int(item.readingProgressAnchor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncHighlights(unsyncedHighlights: [Highlight]) {
|
||||
for highlight in unsyncedHighlights {
|
||||
guard let syncStatus = ServerSyncStatus(rawValue: Int(highlight.serverSyncStatus)) else { continue }
|
||||
|
||||
switch syncStatus {
|
||||
case .isNSync, .isSyncing:
|
||||
break
|
||||
case .needsCreation:
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncHighlightCreation(
|
||||
highlight: InternalHighlight.make(from: highlight),
|
||||
articleId: highlight.linkedItem?.unwrappedID ?? ""
|
||||
)
|
||||
case .needsDeletion:
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncHighlightDeletion(highlightID: highlight.unwrappedID, objectID: highlight.objectID)
|
||||
case .needsUpdate:
|
||||
if let annotation = highlight.annotation {
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncHighlightAttributes(
|
||||
highlightID: highlight.unwrappedID,
|
||||
objectID: highlight.objectID,
|
||||
annotation: annotation
|
||||
)
|
||||
} else {
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,23 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
struct ArticleProps {
|
||||
let htmlContent: String
|
||||
let highlights: [InternalHighlight]
|
||||
}
|
||||
|
||||
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<ArticleContent, ServerError> {
|
||||
enum QueryResult {
|
||||
case success(result: ArticleContent)
|
||||
case success(result: ArticleProps)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let articleSelection = Selection.Article {
|
||||
ArticleContent(
|
||||
ArticleProps(
|
||||
htmlContent: try $0.content(),
|
||||
highlights: try $0.highlights(selection: highlightSelection.list)
|
||||
)
|
||||
|
|
@ -37,12 +43,23 @@ public extension DataService {
|
|||
|
||||
return Deferred {
|
||||
Future { promise in
|
||||
send(query, to: path, headers: headers) { result 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):
|
||||
promise(.success(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))
|
||||
}
|
||||
|
|
@ -56,3 +73,32 @@ public extension DataService {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension DataService {
|
||||
func persistArticleContent(htmlContent: String, slug: String, highlights: [InternalHighlight]) {
|
||||
backgroundContext.perform {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "slug == %@", slug
|
||||
)
|
||||
|
||||
let linkedItem = try? self.backgroundContext.fetch(fetchRequest).first
|
||||
|
||||
if let linkedItem = linkedItem {
|
||||
let highlightObjects = highlights.map {
|
||||
$0.asManagedObject(context: self.backgroundContext)
|
||||
}
|
||||
linkedItem.addToHighlights(NSSet(array: highlightObjects))
|
||||
linkedItem.htmlContent = htmlContent
|
||||
}
|
||||
|
||||
do {
|
||||
try self.backgroundContext.save()
|
||||
print("ArticleContent saved succesfully")
|
||||
} catch {
|
||||
self.backgroundContext.rollback()
|
||||
print("Failed to save ArticleContent: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func labelsPublisher() -> AnyPublisher<[FeedItemLabel], ServerError> {
|
||||
func labelsPublisher() -> AnyPublisher<[NSManagedObjectID], ServerError> {
|
||||
enum QueryResult {
|
||||
case success(result: [FeedItemLabel])
|
||||
case success(result: [InternalLinkedItemLabel])
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
|
|
@ -32,8 +33,12 @@ public extension DataService {
|
|||
switch result {
|
||||
case let .success(payload):
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
promise(.success(result))
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,25 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func articlePublisher(slug: String) -> AnyPublisher<FeedItem, BasicError> {
|
||||
internalViewerPublisher()
|
||||
.flatMap { self.internalArticlePublisher(username: $0.username, slug: slug) }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
extension DataService {
|
||||
func internalArticlePublisher(username: String, slug: String) -> AnyPublisher<FeedItem, BasicError> {
|
||||
enum QueryResult {
|
||||
case success(result: FeedItem)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ArticleResult> {
|
||||
try $0.on(
|
||||
articleSuccess: .init { QueryResult.success(result: try $0.article(selection: homeFeedItemSelection)) },
|
||||
articleError: .init { QueryResult.error(error: try $0.errorCodes().description) }
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.article(username: username, slug: slug, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
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: result):
|
||||
promise(.success(result))
|
||||
case let .error(error: error):
|
||||
promise(.failure(.message(messageText: error.debugDescription)))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "ger article fetch failed")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func libraryItemsPublisher(
|
||||
func fetchLinkedItems(
|
||||
limit: Int,
|
||||
sortDescending: Bool,
|
||||
searchQuery: String?,
|
||||
cursor: String?
|
||||
) -> AnyPublisher<HomeFeedData, ServerError> {
|
||||
) async throws -> HomeFeedData {
|
||||
// Send offline changes to server before fetching items
|
||||
try? await syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
struct InternalHomeFeedData {
|
||||
let items: [InternalLinkedItem]
|
||||
let cursor: String?
|
||||
}
|
||||
|
||||
enum QueryResult {
|
||||
case success(result: HomeFeedData)
|
||||
case success(result: InternalHomeFeedData)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +27,7 @@ public extension DataService {
|
|||
try $0.on(
|
||||
articlesSuccess: .init {
|
||||
QueryResult.success(
|
||||
result: HomeFeedData(
|
||||
result: InternalHomeFeedData(
|
||||
items: try $0.edges(selection: articleEdgeSelection.list),
|
||||
cursor: try $0.pageInfo(selection: Selection.PageInfo {
|
||||
try $0.endCursor()
|
||||
|
|
@ -89,7 +46,7 @@ public extension DataService {
|
|||
sharedOnly: .present(false),
|
||||
sort: OptionalArgument(
|
||||
InputObjects.SortParams(
|
||||
order: .present(sortDescending ? .descending : .ascending),
|
||||
order: .present(.descending),
|
||||
by: .updatedTime
|
||||
)
|
||||
),
|
||||
|
|
@ -103,30 +60,30 @@ public extension DataService {
|
|||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
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: result):
|
||||
promise(.success(result))
|
||||
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):
|
||||
if let context = self?.backgroundContext, let items = result.items.persist(context: context) {
|
||||
continuation.resume(returning: HomeFeedData(items: items.map(\.objectID), cursor: result.cursor))
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
|
||||
}
|
||||
case .error:
|
||||
continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
let homeFeedItemSelection = Selection.Article {
|
||||
FeedItem(
|
||||
private let articleSelection = Selection.Article {
|
||||
InternalLinkedItem(
|
||||
id: try $0.id(),
|
||||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
|
|
@ -137,7 +94,7 @@ let homeFeedItemSelection = Selection.Article {
|
|||
onDeviceImageURLString: nil,
|
||||
documentDirectoryPath: nil,
|
||||
pageURLString: try $0.url(),
|
||||
description: try $0.description(),
|
||||
descriptionText: try $0.description(),
|
||||
publisherURLString: try $0.originalArticleUrl(),
|
||||
author: try $0.author(),
|
||||
publishDate: try $0.publishedAt()?.value,
|
||||
|
|
@ -149,5 +106,5 @@ let homeFeedItemSelection = Selection.Article {
|
|||
}
|
||||
|
||||
private let articleEdgeSelection = Selection.ArticleEdge {
|
||||
try $0.node(selection: homeFeedItemSelection)
|
||||
try $0.node(selection: articleSelection)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func newsletterEmailsPublisher() -> AnyPublisher<[NewsletterEmail], ServerError> {
|
||||
func newsletterEmailsPublisher() -> AnyPublisher<[NSManagedObjectID], ServerError> {
|
||||
enum QueryResult {
|
||||
case success(result: [NewsletterEmail])
|
||||
case success(result: [InternalNewsletterEmail])
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let newsletterEmailSelection = Selection.NewsletterEmail {
|
||||
NewsletterEmail(
|
||||
InternalNewsletterEmail(
|
||||
emailId: try $0.id(),
|
||||
email: try $0.address(),
|
||||
confirmationCode: try $0.confirmationCode()
|
||||
|
|
@ -43,7 +44,11 @@ public extension DataService {
|
|||
case let .success(payload):
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
promise(.success(result))
|
||||
if let newsletterEmailObjectIDs = result.persist(context: self.backgroundContext) {
|
||||
promise(.success(newsletterEmailObjectIDs))
|
||||
} else {
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
case .error:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func pdfHighlightsPublisher(username: String, slug: String) -> AnyPublisher<[Highlight], ServerError> {
|
||||
enum QueryResult {
|
||||
case success(result: [Highlight])
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let highlightSelection = Selection.Highlight {
|
||||
Highlight(
|
||||
id: try $0.id(),
|
||||
shortId: try $0.shortId(),
|
||||
quote: try $0.quote(),
|
||||
prefix: try $0.prefix(),
|
||||
suffix: try $0.suffix(),
|
||||
patch: try $0.patch(),
|
||||
annotation: try $0.annotation(),
|
||||
createdByMe: try $0.createdByMe()
|
||||
)
|
||||
}
|
||||
|
||||
let articleSelection = Selection.Article {
|
||||
try $0.highlights(selection: highlightSelection.list)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ArticleResult> {
|
||||
try $0.on(
|
||||
articleSuccess: .init {
|
||||
QueryResult.success(result: try $0.article(selection: articleSelection))
|
||||
},
|
||||
articleError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.article(username: username, slug: slug, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
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: result):
|
||||
promise(.success(result))
|
||||
case .error:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
case .failure:
|
||||
promise(.failure(.unknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,22 @@
|
|||
import Combine
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
func fetchViewer() async throws -> Viewer {
|
||||
let selection = Selection<Viewer, Objects.User> {
|
||||
Viewer(
|
||||
func fetchViewer() async throws -> NSManagedObjectID {
|
||||
let selection = Selection<ViewerInternal, Objects.User> {
|
||||
ViewerInternal(
|
||||
userID: try $0.id(),
|
||||
username: try $0.profile(
|
||||
selection: .init { try $0.username() }
|
||||
),
|
||||
name: try $0.name(),
|
||||
profileImageURL: try $0.profile(
|
||||
selection: .init { try $0.pictureUrl() }
|
||||
),
|
||||
userID: try $0.id()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -30,12 +31,16 @@ public extension DataService {
|
|||
send(query, to: path, headers: headers) { [weak self] result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
self?.currentViewer = payload.data
|
||||
if UserDefaults.standard.string(forKey: Keys.userIdKey) == nil {
|
||||
UserDefaults.standard.setValue(payload.data.userID, forKey: Keys.userIdKey)
|
||||
DataService.registerIntercomUser?(payload.data.userID)
|
||||
}
|
||||
continuation.resume(returning: payload.data)
|
||||
|
||||
if let self = self, let viewerID = payload.data.persist(context: self.backgroundContext) {
|
||||
continuation.resume(returning: viewerID)
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "coredata error"))
|
||||
}
|
||||
case .failure:
|
||||
continuation.resume(throwing: BasicError.message(messageText: "http error"))
|
||||
}
|
||||
|
|
@ -44,42 +49,32 @@ public extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
extension DataService {
|
||||
@available(*, deprecated, message: "use async version instead")
|
||||
func internalViewerPublisher() -> AnyPublisher<Viewer, BasicError> {
|
||||
let selection = Selection<Viewer, Objects.User> {
|
||||
Viewer(
|
||||
username: try $0.profile(
|
||||
selection: .init { try $0.username() }
|
||||
),
|
||||
name: try $0.name(),
|
||||
profileImageURL: try $0.profile(
|
||||
selection: .init { try $0.pictureUrl() }
|
||||
),
|
||||
userID: try $0.id()
|
||||
)
|
||||
}
|
||||
private struct ViewerInternal {
|
||||
let userID: String
|
||||
let username: String
|
||||
let name: String
|
||||
let profileImageURL: String?
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.me(selection: selection.nonNullOrFail)
|
||||
}
|
||||
func persist(context: NSManagedObjectContext) -> NSManagedObjectID? {
|
||||
var objectID: NSManagedObjectID?
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
context.performAndWait {
|
||||
let viewer = Viewer(context: context)
|
||||
viewer.userID = userID
|
||||
viewer.username = username
|
||||
viewer.name = name
|
||||
viewer.profileImageURL = profileImageURL
|
||||
|
||||
return Deferred {
|
||||
Future { [weak self] promise in
|
||||
send(query, to: path, headers: headers) { result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
self?.currentViewer = payload.data
|
||||
promise(.success(payload.data))
|
||||
case .failure:
|
||||
promise(.failure(.message(messageText: "http error")))
|
||||
}
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Viewer saved succesfully")
|
||||
objectID = viewer.objectID
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to save Viewer: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
return objectID
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import Models
|
|||
import SwiftGraphQL
|
||||
|
||||
let feedItemLabelSelection = Selection.Label {
|
||||
FeedItemLabel(
|
||||
InternalLinkedItemLabel(
|
||||
id: try $0.id(),
|
||||
name: try $0.name(),
|
||||
color: try $0.color(),
|
||||
createdAt: try $0.createdAt()?.value,
|
||||
description: try $0.description()
|
||||
labelDescription: try $0.description()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import Models
|
|||
import SwiftGraphQL
|
||||
|
||||
let highlightSelection = Selection.Highlight {
|
||||
Highlight(
|
||||
InternalHighlight(
|
||||
id: try $0.id(),
|
||||
shortId: try $0.shortId(),
|
||||
quote: try $0.quote(),
|
||||
|
|
@ -10,6 +10,8 @@ let highlightSelection = Selection.Highlight {
|
|||
suffix: try $0.suffix(),
|
||||
patch: try $0.patch(),
|
||||
annotation: try $0.annotation(),
|
||||
createdAt: try $0.createdAt().value,
|
||||
updatedAt: try $0.updatedAt().value,
|
||||
createdByMe: try $0.createdByMe()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
struct InternalHighlight: Encodable {
|
||||
let id: String
|
||||
let shortId: String
|
||||
let quote: String
|
||||
let prefix: String?
|
||||
let suffix: String?
|
||||
let patch: String
|
||||
let annotation: String?
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
let createdByMe: Bool
|
||||
|
||||
func asManagedObject(context: NSManagedObjectContext) -> Highlight {
|
||||
let fetchRequest: NSFetchRequest<Models.Highlight> = Highlight.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "id == %@", id
|
||||
)
|
||||
let existingHighlight = (try? context.fetch(fetchRequest))?.first
|
||||
let highlight = existingHighlight ?? Highlight(entity: Highlight.entity(), insertInto: context)
|
||||
|
||||
highlight.markedForDeletion = false
|
||||
highlight.id = id
|
||||
highlight.shortId = shortId
|
||||
highlight.quote = quote
|
||||
highlight.prefix = prefix
|
||||
highlight.suffix = suffix
|
||||
highlight.patch = patch
|
||||
highlight.annotation = annotation
|
||||
highlight.createdAt = createdAt
|
||||
highlight.updatedAt = updatedAt
|
||||
highlight.createdByMe = createdByMe
|
||||
return highlight
|
||||
}
|
||||
|
||||
static func make(from highlight: Highlight) -> InternalHighlight {
|
||||
InternalHighlight(
|
||||
id: highlight.id ?? "",
|
||||
shortId: highlight.shortId ?? "",
|
||||
quote: highlight.quote ?? "",
|
||||
prefix: highlight.prefix,
|
||||
suffix: highlight.suffix,
|
||||
patch: highlight.patch ?? "",
|
||||
annotation: highlight.annotation,
|
||||
createdAt: highlight.createdAt,
|
||||
updatedAt: highlight.updatedAt,
|
||||
createdByMe: highlight.createdByMe
|
||||
)
|
||||
}
|
||||
|
||||
func persist(
|
||||
context: NSManagedObjectContext,
|
||||
associatedItemID: String?,
|
||||
oldHighlightsIds: [String] = []
|
||||
) {
|
||||
context.perform {
|
||||
let highlight = asManagedObject(context: context)
|
||||
|
||||
if let associatedItemID = associatedItemID {
|
||||
let linkedItem = LinkedItem.lookup(byID: associatedItemID, inContext: context)
|
||||
linkedItem?.addToHighlights(highlight)
|
||||
}
|
||||
|
||||
if !oldHighlightsIds.isEmpty {
|
||||
let fetchRequest: NSFetchRequest<Models.Highlight> = Highlight.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id IN %@", oldHighlightsIds)
|
||||
for highlight in (try? context.fetch(fetchRequest)) ?? [] {
|
||||
context.delete(highlight)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
print("Highlight saved succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
print("Failed to save Highlight: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func encoded() -> [String: Any]? {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == InternalHighlight {
|
||||
var asJSONString: String {
|
||||
let jsonData = try? JSONEncoder().encode(self)
|
||||
guard let jsonData = jsonData else { return "[]" }
|
||||
return String(data: jsonData, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
struct InternalLinkedItem {
|
||||
let id: String
|
||||
let title: String
|
||||
let createdAt: Date
|
||||
let savedAt: Date
|
||||
var readingProgress: Double
|
||||
var readingProgressAnchor: Int
|
||||
let imageURLString: String?
|
||||
let onDeviceImageURLString: String?
|
||||
let documentDirectoryPath: String?
|
||||
let pageURLString: String
|
||||
let descriptionText: String?
|
||||
let publisherURLString: String?
|
||||
let author: String?
|
||||
let publishDate: Date?
|
||||
let slug: String
|
||||
let isArchived: Bool
|
||||
let contentReader: String?
|
||||
var labels: [InternalLinkedItemLabel]
|
||||
|
||||
func asManagedObject(inContext context: NSManagedObjectContext) -> LinkedItem {
|
||||
let existingItem = LinkedItem.lookup(byID: id, inContext: context)
|
||||
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: context)
|
||||
|
||||
linkedItem.id = id
|
||||
linkedItem.title = title
|
||||
linkedItem.createdAt = createdAt
|
||||
linkedItem.savedAt = savedAt
|
||||
linkedItem.readingProgress = readingProgress
|
||||
linkedItem.readingProgressAnchor = Int64(readingProgressAnchor)
|
||||
linkedItem.imageURLString = imageURLString
|
||||
linkedItem.onDeviceImageURLString = onDeviceImageURLString
|
||||
linkedItem.pageURLString = pageURLString
|
||||
linkedItem.descriptionText = descriptionText
|
||||
linkedItem.publisherURLString = publisherURLString
|
||||
linkedItem.author = author
|
||||
linkedItem.publishDate = publishDate
|
||||
linkedItem.slug = slug
|
||||
linkedItem.isArchived = isArchived
|
||||
linkedItem.contentReader = contentReader
|
||||
|
||||
for label in labels {
|
||||
linkedItem.addToLabels(label.asManagedObject(inContext: context))
|
||||
}
|
||||
|
||||
return linkedItem
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == InternalLinkedItem {
|
||||
// TODO: -optimization use batch update?
|
||||
func persist(context: NSManagedObjectContext) -> [LinkedItem]? {
|
||||
var linkedItems: [LinkedItem]?
|
||||
context.performAndWait {
|
||||
linkedItems = map { $0.asManagedObject(inContext: context) }
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
print("LinkedItems saved succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
print("Failed to save LinkedItems: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
return linkedItems
|
||||
}
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func persist(jsonArticle: JSONArticle) -> NSManagedObjectID? {
|
||||
jsonArticle.persistAsLinkedItem(context: backgroundContext)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONArticle {
|
||||
func persistAsLinkedItem(context: NSManagedObjectContext) -> NSManagedObjectID? {
|
||||
var objectID: NSManagedObjectID?
|
||||
|
||||
let internalLinkedItem = InternalLinkedItem(
|
||||
id: id,
|
||||
title: title,
|
||||
createdAt: createdAt,
|
||||
savedAt: savedAt,
|
||||
readingProgress: readingProgressPercent,
|
||||
readingProgressAnchor: readingProgressAnchorIndex,
|
||||
imageURLString: image,
|
||||
onDeviceImageURLString: nil,
|
||||
documentDirectoryPath: nil,
|
||||
pageURLString: url,
|
||||
descriptionText: title,
|
||||
publisherURLString: nil,
|
||||
author: nil,
|
||||
publishDate: nil,
|
||||
slug: slug,
|
||||
isArchived: isArchived,
|
||||
contentReader: contentReader,
|
||||
labels: []
|
||||
)
|
||||
|
||||
context.performAndWait {
|
||||
objectID = internalLinkedItem.asManagedObject(inContext: context).objectID
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem saved succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to save LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
return objectID
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
struct InternalLinkedItemLabel {
|
||||
let id: String
|
||||
let name: String
|
||||
let color: String
|
||||
let createdAt: Date?
|
||||
let labelDescription: String?
|
||||
|
||||
func persist(context: NSManagedObjectContext) -> NSManagedObjectID? {
|
||||
var objectID: NSManagedObjectID?
|
||||
|
||||
context.performAndWait {
|
||||
let label = asManagedObject(inContext: context)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItemLabel saved succesfully")
|
||||
objectID = label.objectID
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to save LinkedItemLabel: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
return objectID
|
||||
}
|
||||
|
||||
func asManagedObject(inContext context: NSManagedObjectContext) -> LinkedItemLabel {
|
||||
let existingItem = LinkedItemLabel.lookup(byID: id, inContext: context)
|
||||
let label = existingItem ?? LinkedItemLabel(entity: LinkedItemLabel.entity(), insertInto: context)
|
||||
label.id = id
|
||||
label.name = name
|
||||
label.color = color
|
||||
label.createdAt = createdAt
|
||||
label.labelDescription = labelDescription
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
extension LinkedItemLabel {
|
||||
public var unwrappedID: String { id ?? "" }
|
||||
|
||||
static func lookup(byID labelID: String, inContext context: NSManagedObjectContext) -> LinkedItemLabel? {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "id == %@", labelID
|
||||
)
|
||||
|
||||
var label: LinkedItemLabel?
|
||||
|
||||
context.performAndWait {
|
||||
label = (try? context.fetch(fetchRequest))?.first
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
func update(
|
||||
inContext context: NSManagedObjectContext,
|
||||
newName: String? = nil,
|
||||
newColor: String? = nil,
|
||||
newLabelDescription: String? = nil
|
||||
) {
|
||||
context.perform {
|
||||
if let newName = newName {
|
||||
self.name = newName
|
||||
}
|
||||
|
||||
if let newColor = newColor {
|
||||
self.color = newColor
|
||||
}
|
||||
|
||||
if let newLabelDescription = newLabelDescription {
|
||||
self.labelDescription = newLabelDescription
|
||||
}
|
||||
|
||||
guard context.hasChanges else { return }
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItemLabel updated succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to update LinkedItemLabel: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remove(inContext context: NSManagedObjectContext) {
|
||||
context.perform {
|
||||
context.delete(self)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItemLabel removed")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to remove LinkedItemLabel: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == InternalLinkedItemLabel {
|
||||
func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? {
|
||||
var result: [NSManagedObjectID]?
|
||||
|
||||
context.performAndWait {
|
||||
let labels = map { $0.asManagedObject(inContext: context) }
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("labels saved succesfully")
|
||||
result = labels.map(\.objectID)
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to save labels: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
struct InternalNewsletterEmail {
|
||||
let emailId: String
|
||||
let email: String
|
||||
let confirmationCode: String?
|
||||
|
||||
func persist(context: NSManagedObjectContext) -> NSManagedObjectID? {
|
||||
var objectID: NSManagedObjectID?
|
||||
|
||||
context.performAndWait {
|
||||
let newsletterEmail = asManagedObject(inContext: context)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("NewsletterEmail saved succesfully")
|
||||
objectID = newsletterEmail.objectID
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to save NewsletterEmail: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
return objectID
|
||||
}
|
||||
|
||||
func asManagedObject(inContext context: NSManagedObjectContext) -> NewsletterEmail {
|
||||
let newsletterEmail = NewsletterEmail(entity: NewsletterEmail.entity(), insertInto: context)
|
||||
newsletterEmail.emailId = emailId
|
||||
newsletterEmail.email = email
|
||||
newsletterEmail.confirmationCode = confirmationCode
|
||||
return newsletterEmail
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == InternalNewsletterEmail {
|
||||
func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? {
|
||||
var result: [NSManagedObjectID]?
|
||||
|
||||
context.performAndWait {
|
||||
let newsletterEmails = map { $0.asManagedObject(inContext: context) }
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("NewsletterEmail saved succesfully")
|
||||
result = newsletterEmails.map(\.objectID)
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to save NewsletterEmail: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,12 @@ import Foundation
|
|||
import Models
|
||||
|
||||
public extension NSNotification {
|
||||
static let PushFeedItem = Notification.Name("PushFeedItem")
|
||||
static let PushJSONArticle = Notification.Name("PushJSONArticle")
|
||||
static let OperationSuccess = Notification.Name("OperationSuccess")
|
||||
static let OperationFailure = Notification.Name("OperationFailure")
|
||||
|
||||
static var pushFeedItemPublisher: NotificationCenter.Publisher {
|
||||
NotificationCenter.default.publisher(for: PushFeedItem)
|
||||
NotificationCenter.default.publisher(for: PushJSONArticle)
|
||||
}
|
||||
|
||||
static var operationSuccessPublisher: NotificationCenter.Publisher {
|
||||
|
|
@ -32,8 +32,12 @@ public extension NSNotification {
|
|||
return nil
|
||||
}
|
||||
|
||||
static func pushFeedItem(feedItem: FeedItem) {
|
||||
NotificationCenter.default.post(name: NSNotification.PushFeedItem, object: nil, userInfo: ["feedItem": feedItem])
|
||||
static func pushJSONArticle(article: JSONArticle) {
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.PushJSONArticle,
|
||||
object: nil,
|
||||
userInfo: ["article": article]
|
||||
)
|
||||
}
|
||||
|
||||
static func operationSuccess(message: String) {
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import Combine
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
final class CachedPDFHighlights {
|
||||
init(pdfID: String, highlights: [Highlight], removedHighlightIDs: [String]) {
|
||||
self.pdfID = pdfID
|
||||
self.highlights = highlights
|
||||
self.removedHighlightIDs = removedHighlightIDs
|
||||
}
|
||||
|
||||
let pdfID: String
|
||||
var highlights: [Highlight]
|
||||
var removedHighlightIDs: [String]
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func cachedHighlights(pdfID: String) -> [Highlight] {
|
||||
fetchCachedHighlights(pdfID: pdfID as NSString)?.highlights ?? []
|
||||
}
|
||||
|
||||
func fetchRemovedHighlightIds(pdfID: String) -> [String] {
|
||||
fetchCachedHighlights(pdfID: pdfID as NSString)?.removedHighlightIDs ?? []
|
||||
}
|
||||
|
||||
func persistHighlight(pdfID: String, highlight: Highlight) {
|
||||
let cachedHighlights =
|
||||
fetchCachedHighlights(pdfID: pdfID as NSString)
|
||||
?? CachedPDFHighlights(pdfID: pdfID, highlights: [], removedHighlightIDs: [])
|
||||
|
||||
cachedHighlights.highlights.append(highlight)
|
||||
insertCachedHighlights(highlights: cachedHighlights, pdfID: pdfID as NSString)
|
||||
}
|
||||
|
||||
func removeHighlights(pdfID: String, highlightIds: [String]) {
|
||||
let cachedHighlights =
|
||||
fetchCachedHighlights(pdfID: pdfID as NSString)
|
||||
?? CachedPDFHighlights(pdfID: pdfID, highlights: [], removedHighlightIDs: [])
|
||||
|
||||
cachedHighlights.removedHighlightIDs.append(contentsOf: highlightIds)
|
||||
insertCachedHighlights(highlights: cachedHighlights, pdfID: pdfID as NSString)
|
||||
}
|
||||
|
||||
private func fetchCachedHighlights(pdfID: NSString) -> CachedPDFHighlights? {
|
||||
var cachedHighlights: CachedPDFHighlights?
|
||||
highlightsCacheQueue.sync {
|
||||
cachedHighlights = highlightsCache.object(forKey: pdfID as NSString)
|
||||
}
|
||||
return cachedHighlights
|
||||
}
|
||||
|
||||
private func insertCachedHighlights(highlights: CachedPDFHighlights, pdfID: NSString) {
|
||||
highlightsCacheQueue.async(flags: .barrier) {
|
||||
self.highlightsCache.setObject(highlights, forKey: pdfID as AnyObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apple/OmnivoreKit/Sources/Utils/NSSetExtension.swift
Normal file
10
apple/OmnivoreKit/Sources/Utils/NSSetExtension.swift
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import Foundation
|
||||
|
||||
public extension Optional where Wrapped == NSSet {
|
||||
func asArray<T: Hashable>(of _: T.Type) -> [T] {
|
||||
if let set = self as? Set<T> {
|
||||
return Array(set)
|
||||
}
|
||||
return [T]()
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import WebKit
|
|||
public final class WebAppWrapperViewModel: ObservableObject {
|
||||
public enum Action {
|
||||
case shareHighlight(highlightID: String)
|
||||
case updateReadingProgess(progress: Int)
|
||||
}
|
||||
|
||||
public var subscriptions = Set<AnyCancellable>()
|
||||
|
|
@ -87,12 +86,6 @@ public struct WebAppWrapperView: View {
|
|||
if message.name == WebViewAction.highlightAction.rawValue {
|
||||
handleHighlightAction(message: message)
|
||||
}
|
||||
|
||||
if message.name == WebViewAction.readingProgressUpdate.rawValue {
|
||||
guard let messageBody = message.body as? [String: Double] else { return }
|
||||
guard let progress = messageBody["progress"] else { return }
|
||||
viewModel.performActionSubject.send(.updateReadingProgess(progress: Int(progress)))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHighlightAction(message: WKScriptMessage) {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ public enum GridCardAction {
|
|||
|
||||
public struct GridCard: View {
|
||||
@Binding var isContextMenuOpen: Bool
|
||||
let item: FeedItem
|
||||
let item: LinkedItem
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
let tapAction: () -> Void
|
||||
|
||||
public init(
|
||||
item: FeedItem,
|
||||
item: LinkedItem,
|
||||
isContextMenuOpen: Binding<Bool>,
|
||||
actionHandler: @escaping (GridCardAction) -> Void,
|
||||
tapAction: @escaping () -> Void
|
||||
|
|
@ -77,7 +77,7 @@ public struct GridCard: View {
|
|||
// Title, Subtitle, Menu Button
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.title)
|
||||
Text(item.unwrappedTitle)
|
||||
.font(.appHeadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
|
|
@ -117,7 +117,7 @@ public struct GridCard: View {
|
|||
|
||||
// Link description and image
|
||||
HStack(alignment: .top) {
|
||||
Text(item.description ?? item.title)
|
||||
Text(item.descriptionText ?? item.unwrappedTitle)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(nil)
|
||||
|
|
@ -152,7 +152,7 @@ public struct GridCard: View {
|
|||
if FeatureFlag.enableLabels {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(item.labels, id: \.self) {
|
||||
ForEach(item.labels.asArray(of: LinkedItemLabel.self), id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
}
|
||||
Spacer()
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@ import SwiftUI
|
|||
import Utils
|
||||
|
||||
public struct FeedCard: View {
|
||||
let item: FeedItem
|
||||
let item: LinkedItem
|
||||
|
||||
public init(item: FeedItem) {
|
||||
public init(item: LinkedItem) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.title)
|
||||
Text(item.unwrappedTitle)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(2)
|
||||
|
|
|
|||
|
|
@ -2,35 +2,25 @@ import SwiftUI
|
|||
|
||||
public struct Snackbar: View {
|
||||
@Binding var isShowing: Bool
|
||||
private let presenting: AnyView
|
||||
private let presentingView: AnyView
|
||||
private let text: Text
|
||||
private let actionText: Text?
|
||||
private let action: (() -> Void)?
|
||||
|
||||
private var isBeingDismissedByAction: Bool {
|
||||
actionText != nil && action != nil
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
|
||||
init<Presenting>(
|
||||
init<PresentingView>(
|
||||
isShowing: Binding<Bool>,
|
||||
presenting: Presenting,
|
||||
text: Text,
|
||||
actionText: Text? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) where Presenting: View {
|
||||
presentingView: PresentingView,
|
||||
text: Text
|
||||
) where PresentingView: View {
|
||||
self._isShowing = isShowing
|
||||
self.presenting = AnyView(presenting)
|
||||
self.presentingView = AnyView(presentingView)
|
||||
self.text = text
|
||||
self.actionText = actionText
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .center) {
|
||||
self.presenting
|
||||
presentingView
|
||||
VStack {
|
||||
Spacer()
|
||||
if self.isShowing {
|
||||
|
|
@ -39,17 +29,6 @@ public struct Snackbar: View {
|
|||
.font(.appCallout)
|
||||
.foregroundColor(self.colorScheme == .light ? .appGrayText : .appTextDefault)
|
||||
Spacer()
|
||||
if self.actionText != nil, self.action != nil {
|
||||
self.actionText!
|
||||
.bold()
|
||||
.foregroundColor(self.colorScheme == .light ? .appGrayText : .appTextDefault)
|
||||
.onTapGesture {
|
||||
self.action?()
|
||||
withAnimation {
|
||||
self.isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: min(380, geometry.size.width * 0.96), height: 44)
|
||||
|
|
@ -58,14 +37,6 @@ public struct Snackbar: View {
|
|||
.offset(x: 0, y: -8)
|
||||
.shadow(color: .gray, radius: 2)
|
||||
.animation(Animation.spring())
|
||||
.onAppear {
|
||||
guard !self.isBeingDismissedByAction else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
self.isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -74,16 +45,7 @@ public struct Snackbar: View {
|
|||
}
|
||||
|
||||
public extension View {
|
||||
func snackBar(
|
||||
isShowing: Binding<Bool>,
|
||||
text: Text,
|
||||
actionText: Text? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
Snackbar(isShowing: isShowing,
|
||||
presenting: self,
|
||||
text: text,
|
||||
actionText: actionText,
|
||||
action: action)
|
||||
func snackBar(isShowing: Binding<Bool>, message: String?) -> some View {
|
||||
Snackbar(isShowing: isShowing, presentingView: self, text: Text(message ?? ""))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@ import SwiftUI
|
|||
|
||||
public struct SnoozeView: View {
|
||||
@Binding var snoozePresented: Bool
|
||||
@Binding var itemToSnooze: FeedItem?
|
||||
@Binding var itemToSnoozeID: String?
|
||||
let snoozeAction: (SnoozeActionParams) -> Void
|
||||
|
||||
public init(
|
||||
snoozePresented: Binding<Bool>,
|
||||
itemToSnooze: Binding<FeedItem?>,
|
||||
itemToSnoozeID: Binding<String?>,
|
||||
snoozeAction: @escaping (SnoozeActionParams) -> Void
|
||||
) {
|
||||
self._snoozePresented = snoozePresented
|
||||
self._itemToSnooze = itemToSnooze
|
||||
self._itemToSnoozeID = itemToSnoozeID
|
||||
self.snoozeAction = snoozeAction
|
||||
}
|
||||
|
||||
|
|
@ -36,18 +36,18 @@ public struct SnoozeView: View {
|
|||
}
|
||||
|
||||
private func snoozeItem(_ snooze: Snooze) {
|
||||
if let item = itemToSnooze {
|
||||
if let itemID = itemToSnoozeID {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
snoozeAction(
|
||||
SnoozeActionParams(
|
||||
feedItemId: item.id,
|
||||
feedItemId: itemID,
|
||||
snoozeUntilDate: snooze.until,
|
||||
successMessage: "Snoozed until \(snooze.untilStr)"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
itemToSnooze = nil
|
||||
itemToSnoozeID = nil
|
||||
snoozePresented = false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ public struct TextChip: View {
|
|||
self.color = color
|
||||
}
|
||||
|
||||
public init?(feedItemLabel: FeedItemLabel) {
|
||||
guard let color = Color(hex: feedItemLabel.color) else { return nil }
|
||||
public init?(feedItemLabel: LinkedItemLabel) {
|
||||
guard let color = Color(hex: feedItemLabel.color ?? "") else { return nil }
|
||||
|
||||
self.text = feedItemLabel.name
|
||||
self.text = feedItemLabel.name ?? ""
|
||||
self.color = color
|
||||
}
|
||||
|
||||
|
|
@ -41,12 +41,12 @@ public struct TextChipButton: View {
|
|||
}
|
||||
|
||||
public static func makeRemovableLabelButton(
|
||||
feedItemLabel: FeedItemLabel,
|
||||
feedItemLabel: LinkedItemLabel,
|
||||
onTap: @escaping () -> Void
|
||||
) -> TextChipButton {
|
||||
TextChipButton(
|
||||
title: feedItemLabel.name,
|
||||
color: Color(hex: feedItemLabel.color) ?? .appButtonBackground,
|
||||
title: feedItemLabel.name ?? "",
|
||||
color: Color(hex: feedItemLabel.color ?? "") ?? .appButtonBackground,
|
||||
actionType: .remove,
|
||||
onTap: onTap
|
||||
)
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ import Utils
|
|||
|
||||
coordinator.viewer = self
|
||||
|
||||
if viewModel.feedItem.readingProgressAnchor > 0 {
|
||||
let pageIndex = UInt(viewModel.feedItem.readingProgressAnchor)
|
||||
if viewModel.linkedItem.readingProgressAnchor > 0 {
|
||||
let pageIndex = UInt(viewModel.linkedItem.readingProgressAnchor)
|
||||
controller.setPageIndex(pageIndex, animated: false)
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ import Utils
|
|||
let pageIndex = Int(event.pageIndex)
|
||||
if let totalPageCount = controller.document?.pageCount {
|
||||
let percent = min(100, max(0, ((Double(pageIndex) + 1.0) / Double(totalPageCount)) * 100.0))
|
||||
if percent > self.viewModel.feedItem.readingProgress {
|
||||
if percent > self.viewModel.linkedItem.readingProgress {
|
||||
self.viewModel.updateItemReadProgress(percent: percent, anchorIndex: pageIndex)
|
||||
}
|
||||
}
|
||||
|
|
@ -199,7 +199,7 @@ import Utils
|
|||
"id": highlightID,
|
||||
"shortId": shortId,
|
||||
"quote": quote,
|
||||
"articleId": viewModel.feedItem.id
|
||||
"articleId": viewModel.linkedItem.unwrappedID
|
||||
]
|
||||
]
|
||||
document.add(annotations: [highlight])
|
||||
|
|
@ -301,10 +301,10 @@ import Utils
|
|||
}
|
||||
|
||||
private func applyHighlights(documentProvider: PDFDocumentProvider) {
|
||||
viewModel.loadHighlights { [weak self] highlights in
|
||||
viewModel.loadHighlightPatches { [weak self] highlightPatches in
|
||||
var annnotations: [Annotation] = []
|
||||
for highlight in highlights {
|
||||
guard let data = highlight.patch.data(using: String.Encoding.utf8) else { continue }
|
||||
for patch in highlightPatches {
|
||||
guard let data = patch.data(using: String.Encoding.utf8) else { continue }
|
||||
let annotation = try? Annotation(fromInstantJSON: data, documentProvider: documentProvider)
|
||||
guard let annotation = annotation else { continue }
|
||||
annnotations.append(annotation)
|
||||
|
|
|
|||
|
|
@ -55,14 +55,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
|
|||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
if let linkData = userInfo["link"] as? String {
|
||||
guard let jsonData = Data(base64Encoded: linkData) else { return }
|
||||
if let item = FeedItem.fromJsonArticle(linkData: jsonData) {
|
||||
NSNotification.pushFeedItem(feedItem: item)
|
||||
|
||||
if let article = try? JSONDecoder().decode(JSONArticle.self, from: jsonData) {
|
||||
NSNotification.pushJSONArticle(article: article)
|
||||
}
|
||||
}
|
||||
|
||||
print(userInfo)
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue