Merge pull request #452 from omnivore-app/feature/core-data-integration

Feature/core data integration
This commit is contained in:
Satindar Dhillon 2022-04-26 21:42:38 -07:00 committed by GitHub
commit 7cbfc96b87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 1804 additions and 1337 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) ?? "[]"
}
}

View file

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

View file

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

View file

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

View 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)")
}
}
}
}

View 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)")
}
}
}
}

View file

@ -0,0 +1,11 @@
import Foundation
public extension NewsletterEmail {
var unwrappedEmailId: String {
emailId ?? ""
}
var unwrappedEmail: String {
email ?? ""
}
}

View file

@ -0,0 +1,9 @@
import Foundation
public enum ServerSyncStatus: Int {
case isNSync
case isSyncing
case needsDeletion
case needsCreation
case needsUpdate
}

View file

@ -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 ?? ""
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,10 @@
import Foundation
import Models
public enum CoreDataError: Error {
case general
}
public enum ServerError: String, Error {
case noConnection
case unauthenticated

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) ?? "[]"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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]()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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