Abstract out fetching from view model so we can better handle multiple fetch folders
Rename LinkedItem to LibraryItem More on following Add new fetcher Tab bar
|
|
@ -27,6 +27,15 @@
|
|||
"version" : "0.9.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "engine",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nathantannar4/Engine",
|
||||
"state" : {
|
||||
"revision" : "31949c114698e4fd43fd76290913bca415fa87bc",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "files",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
@ -207,6 +216,15 @@
|
|||
"version" : "1.19.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-syntax.git",
|
||||
"state" : {
|
||||
"revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036",
|
||||
"version" : "509.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftformat",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
@ -225,6 +243,24 @@
|
|||
"version" : "0.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "transmission",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nathantannar4/Transmission",
|
||||
"state" : {
|
||||
"revision" : "9517912f8f528c777f86f7896b5c35d7e43fa916",
|
||||
"version" : "1.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "turbocharger",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nathantannar4/Turbocharger",
|
||||
"state" : {
|
||||
"revision" : "b4201ba0bc094facf6cabe3b36fd3763b51ccfc8",
|
||||
"version" : "1.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "valet",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ let package = Package(
|
|||
.library(name: "Services", targets: ["Services"]),
|
||||
.library(name: "Models", targets: ["Models"]),
|
||||
.library(name: "Utils", targets: ["Utils"])
|
||||
|
||||
],
|
||||
dependencies: dependencies,
|
||||
targets: [
|
||||
|
|
@ -26,7 +27,8 @@ let package = Package(
|
|||
"Models",
|
||||
.product(name: "Introspect", package: "SwiftUI-Introspect"),
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
|
||||
.productItem(name: "PopupView", package: "PopupView")
|
||||
.productItem(name: "PopupView", package: "PopupView"),
|
||||
.product(name: "Transmission", package: "Transmission")
|
||||
],
|
||||
resources: [.process("Resources")]
|
||||
),
|
||||
|
|
@ -70,7 +72,8 @@ var dependencies: [Package.Dependency] {
|
|||
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2"),
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0"),
|
||||
.package(url: "https://github.com/exyte/PopupView.git", from: "2.6.0"),
|
||||
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0")
|
||||
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0"),
|
||||
.package(url: "https://github.com/nathantannar4/Transmission", from: "1.0.1")
|
||||
]
|
||||
// Comment out following line for macOS build
|
||||
deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "13.1.0"))
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
@Published public var url: String?
|
||||
@Published public var iconURL: URL?
|
||||
@Published public var highlightData: HighlightData?
|
||||
@Published public var linkedItem: LinkedItem?
|
||||
@Published public var linkedItem: Models.LibraryItem?
|
||||
@Published public var requestId = UUID().uuidString.lowercased()
|
||||
@Published var debugText: String?
|
||||
@Published var noteText: String = ""
|
||||
|
|
@ -204,7 +204,7 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
if let objectID = objectID {
|
||||
self.linkedItem = self.services.dataService.viewContext.object(with: objectID) as? LinkedItem
|
||||
self.linkedItem = self.services.dataService.viewContext.object(with: objectID) as? Models.LibraryItem
|
||||
if let title = self.linkedItem?.title {
|
||||
self.title = title
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// swiftlint:disable line_length
|
||||
|
||||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
|
|
@ -5,155 +7,122 @@ import SwiftUI
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor final class BriefingViewModel: ObservableObject {
|
||||
@Published var item: LinkedItem?
|
||||
let BRIEFING = """
|
||||
## Inbox
|
||||
|
||||
func loadItem(articleId: String, dataService: DataService) async {
|
||||
let item = await dataService.viewContext.perform {
|
||||
LinkedItem.lookup(byID: articleId, inContext: dataService.viewContext)
|
||||
// dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem
|
||||
}
|
||||
- [ ] Who owns Real Madrid? How 'socios' remain in control of Los Blancos with president Florentino Perez at the helm | Sporting News Singapore | Kyle Bonn
|
||||
|
||||
if let item = item {
|
||||
self.item = item
|
||||
}
|
||||
}
|
||||
## Subscriptions
|
||||
|
||||
// func handleArchiveAction(dataService: DataService) {
|
||||
// guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
|
||||
// dataService.archiveLink(objectID: objectID, archived: !isItemArchived)
|
||||
// showInSnackbar(!isItemArchived ? "Link archived" : "Link moved to Inbox")
|
||||
// }
|
||||
//
|
||||
// func handleDeleteAction(dataService: DataService) {
|
||||
// guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
|
||||
// showInSnackbar("Link removed")
|
||||
// dataService.removeLink(objectID: objectID)
|
||||
// }
|
||||
//
|
||||
// func updateItemReadStatus(dataService: DataService) {
|
||||
// guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
|
||||
//
|
||||
// dataService.updateLinkReadingProgress(
|
||||
// itemID: itemID,
|
||||
// readingProgress: isItemRead ? 0 : 100,
|
||||
// anchorIndex: 0
|
||||
// )
|
||||
// }
|
||||
- [ ] Astral Codex Ten
|
||||
- [ ] In Continued Defense Of Effective Altruism
|
||||
- [ ] God Help Us, Let's Try To Understand AI Monosemanticity
|
||||
- [ ] Open Thread 304
|
||||
- [ ] The Pragmatic Engineer: Holiday Season Gift Ideas for Techies
|
||||
- [ ] Golang Weekly: 🥶 Like me, Go 1.22 is now frozen
|
||||
- [ ] Linus Ekenstam at Inside My Head: Enhance/upscale anything - Magnific AI
|
||||
- [ ] Not Boring: Narrative Tug-of-War
|
||||
- [ ] Colin Wright: One Sentence News / November 28, 2023
|
||||
- [ ] Lenny's Newsletter
|
||||
- [ ] Lessons from going freemium: a decision that broke our business
|
||||
- [ ] Billion dollar failures, and billion dollar success | Tom Conrad (Quibi, Pandora, Pets.com, Snap, Zero)
|
||||
- [ ] Aeon+Psyche Daily
|
||||
- [ ] When bereavement turns to activism
|
||||
- [ ] Why training won’t solve implicit bias
|
||||
- [ ] Etgar Keret from Alphabet Soup: Alternative Fun Facts: Friendship Baby
|
||||
- [ ] Huddle Up: How Populous Became The Top Sports Architecture Firm In The World
|
||||
- [ ] Write With AI: How To (Productively) Edit Your Writing With ChatGPT
|
||||
|
||||
// private func trackReadEvent() {
|
||||
// guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
|
||||
// guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return }
|
||||
// guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.originalArticleURL else { return }
|
||||
//
|
||||
// EventTracker.track(
|
||||
// .linkRead(
|
||||
// linkID: itemID,
|
||||
// slug: slug,
|
||||
// originalArticleURL: originalArticleURL
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
## Read
|
||||
|
||||
- [ ] Big brands keep dropping X over antisemitism; $75M loss, report estimates | Ars Technica
|
||||
- [ ] My Hero [Comic]Geeks are Sexy Technology News
|
||||
- [ ] 'Project DNA': How Japan's J1 League became a 'flair factory' for Europe's top clubs
|
||||
- [ ] One Sentence News / November 27, 2023
|
||||
- [ ] Only 1 day left to claim your 30% discount on annual membership!
|
||||
|
||||
## Highlights
|
||||
|
||||
- [ ] Big brands keep dropping X over antisemitism; $75M loss, report estimates | Ars Technica
|
||||
- [ ] Musk [responded](https://twitter.com/elonmusk/status/1728164110137725260) to an X user who [said](https://x.com/JohnnaCrider1/status/1728155588993970484?s=20) that some users were "mad" over lower ad revenue-sharing, saying there was "not much we can do if advertisers boycott or reduce spend on our platform."
|
||||
- [ ] 'Project DNA': How Japan's J1 League became a 'flair factory' for Europe's top clubs
|
||||
- [ ] communities are being bound to clubs to create a nascent cultural heritage.
|
||||
- [ ] The FA and the J1 League brought prefectures, associations, clubs, coaches, universities and schools together to work for a common good. There is a Japanese concept – “ikigai” – which describes the sourcing of meaning or fulfilment from a purpose. The national team was the ikigai.
|
||||
- [ ] That aim is governed by regulation. It is now mandatory for every club to run their own academy with at least Under-15 and Under-18 teams. There are limits on the number of foreigners on each squad. The first team starting XI must contain at least two homegrown players and one Under-21 player.
|
||||
- [ ] Clubs are also rewarded for developing and playing academy graduates. The earlier the player moves, the more development they get in a different footballing culture and the more space it allows for another young player to replace them.
|
||||
- [ ] But in 2016, the Japanese FA created “Project DNA”, an initiative that aimed to adjust and amend existing training methods to produce more rounded footballers. They sent coaches to European clubs, including those in the Premier League. They studied and they cherry-picked and they vowed that insularity should never rule because Japan would never have enough alone.
|
||||
- [ ] Japan’s most successful recent exports (Mitoma at Brighton, Daichi Kamada at Eintracht Frankfurt, Takefusa Kubo at Real Sociedad) are fun, unpredictable, exciting attacking players.
|
||||
|
||||
## Archived
|
||||
|
||||
- [ ] Why Hunter Biden Asked to Testify Publicly in Impeachment Bid | TIME
|
||||
"""
|
||||
|
||||
struct BriefingSection {
|
||||
let title: String
|
||||
var items = [String]()
|
||||
}
|
||||
|
||||
struct BriefingView: View {
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||
@MainActor final class BriefingViewModel: ObservableObject {
|
||||
@Published var sections = [BriefingSection]()
|
||||
|
||||
static let navBarHeight = 50.0
|
||||
let articleId: String
|
||||
func load() async {
|
||||
var currentSection: BriefingSection?
|
||||
var sections = [BriefingSection]()
|
||||
let lines = BRIEFING.components(separatedBy: .newlines)
|
||||
|
||||
@StateObject private var viewModel = BriefingViewModel()
|
||||
@State private var showFontSizePopover = false
|
||||
@State private var showTitleEdit = false
|
||||
@State private var navBarVisibilityRatio = 1.0
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
var removeLinkToolbarItem: some View {
|
||||
Button(
|
||||
action: { print("delete item action") },
|
||||
label: {
|
||||
Image(systemName: "trash")
|
||||
lines.forEach { line in
|
||||
if line.starts(with: "## ") {
|
||||
if let currentSection = currentSection {
|
||||
sections.append(currentSection)
|
||||
}
|
||||
let title = line.replacingOccurrences(of: "## ", with: "")
|
||||
currentSection = BriefingSection(title: title)
|
||||
} else if line.isEmpty {
|
||||
return
|
||||
} else {
|
||||
currentSection?.items.append(line)
|
||||
}
|
||||
)
|
||||
}
|
||||
if let currentSection = currentSection {
|
||||
sections.append(currentSection)
|
||||
}
|
||||
self.sections = sections
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct BriefingView: View {
|
||||
@StateObject private var viewModel = BriefingViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack { // Using ZStack so .task can be used on if/else body
|
||||
if let item = viewModel.item {
|
||||
WebReaderContainerView(item: item, pop: {})
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadItem(articleId: articleId, dataService: dataService)
|
||||
NotificationCenter.default.post(Notification(name: Notification.Name("ReaderSettingsChanged")))
|
||||
// static var readerSettingsChangedPublisher: NotificationCenter.Publisher {
|
||||
// NotificationCenter.default.publisher(for: ReaderSettingsChanged)
|
||||
// }
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
var navBar: some View {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showFontSizePopover.toggle() },
|
||||
label: {
|
||||
Image(systemName: "textformat.size")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Menu(
|
||||
content: {
|
||||
Group {
|
||||
Button(
|
||||
action: { showTitleEdit = true },
|
||||
label: { Label("Edit Info", systemImage: "info.circle") }
|
||||
)
|
||||
// Button(
|
||||
// action: { viewModel.handleArchiveAction(dataService: dataService) },
|
||||
// label: {
|
||||
// Label(
|
||||
// viewModel.isItemArchived ? "Unarchive" : "Archive",
|
||||
// systemImage: viewModel.isItemArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
// )
|
||||
// }
|
||||
// )
|
||||
Button(
|
||||
action: { showDeleteConfirmation = true },
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
List {
|
||||
ForEach(viewModel.sections, id: \.title) { section in
|
||||
Section(section.title) {
|
||||
ForEach(section.items, id: \.self) { item in
|
||||
if item.starts(with: "- [ ] ") {
|
||||
let idx = item.index(item.startIndex, offsetBy: 6)
|
||||
HStack {
|
||||
Image(systemName: "square")
|
||||
Text(item.suffix(from: idx))
|
||||
.lineLimit(2)
|
||||
}
|
||||
} else if item.starts(with: " - [ ] ") {
|
||||
let idx = item.index(item.startIndex, offsetBy: 8)
|
||||
HStack {
|
||||
Text(" ")
|
||||
Image(systemName: "square")
|
||||
Text(item.suffix(from: idx))
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
.opacity(navBarVisibilityRatio)
|
||||
.background(Color.systemBackground)
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
|
||||
// Button("Remove Link", role: .destructive) {
|
||||
// viewModel.handleDeleteAction(dataService: dataService)
|
||||
// }
|
||||
Button(LocalText.cancelGeneric, role: .cancel, action: {})
|
||||
}
|
||||
.sheet(isPresented: $showTitleEdit) {
|
||||
if let item = viewModel.item {
|
||||
LinkedItemMetadataEditView(item: item)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ struct NoteItemParams: Identifiable {
|
|||
@Published var highlightItems = [HighlightListItemParams]()
|
||||
|
||||
func load(itemObjectID: NSManagedObjectID, dataService: DataService) {
|
||||
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? LinkedItem {
|
||||
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem {
|
||||
loadHighlights(item: linkedItem)
|
||||
}
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ struct NoteItemParams: Identifiable {
|
|||
let highlightId = UUID().uuidString.lowercased()
|
||||
let shortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8)
|
||||
|
||||
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? LinkedItem {
|
||||
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem {
|
||||
noteItem = NoteItemParams(highlightID: highlightId, annotation: annotation)
|
||||
let highlight = dataService.createNote(shortId: shortId,
|
||||
highlightID: highlightId,
|
||||
|
|
@ -105,7 +105,7 @@ struct NoteItemParams: Identifiable {
|
|||
highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
private func loadHighlights(item: LinkedItem) {
|
||||
private func loadHighlights(item: Models.LibraryItem) {
|
||||
let unsortedHighlights = item.highlights.asArray(of: Highlight.self)
|
||||
.filter { $0.type == "HIGHLIGHT" && $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
import Views
|
||||
|
||||
struct MacFeedCardNavigationLink: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
let item: LinkedItem
|
||||
let item: Models.LibraryItem
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
|
|
@ -31,19 +32,50 @@ struct FeedCardNavigationLink: View {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
let item: LinkedItem
|
||||
let item: Models.LibraryItem
|
||||
let isInMultiSelectMode: Bool
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LibraryItemCard(item: item, viewer: dataService.currentViewer)
|
||||
NavigationLink(destination: LinkItemDetailView(
|
||||
linkedItemObjectID: item.objectID,
|
||||
isPDF: item.isPDF
|
||||
), label: {
|
||||
EmptyView()
|
||||
}).opacity(0)
|
||||
// PresentationLink({
|
||||
// <#code#>
|
||||
// } label: {
|
||||
// EmptyView()
|
||||
// }).opacity(0)
|
||||
|
||||
// public init(
|
||||
// edge: Edge = .bottom,
|
||||
// prefersScaleEffect: Bool = true,
|
||||
// preferredCornerRadius: CGFloat? = nil,
|
||||
// isInteractive: Bool = true,
|
||||
// options: Options = .init(modalPresentationCapturesStatusBarAppearance: true)
|
||||
// ) {
|
||||
// self.edge = edge
|
||||
// self.prefersScaleEffect = prefersScaleEffect
|
||||
// self.preferredCornerRadius = preferredCornerRadius
|
||||
// self.isInteractive = isInteractive
|
||||
// self.options = options
|
||||
// }
|
||||
//
|
||||
PresentationLink(
|
||||
transition: PresentationLinkTransition.slide(
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
|
||||
options:
|
||||
PresentationLinkTransition.Options(
|
||||
modalPresentationCapturesStatusBarAppearance: true
|
||||
))),
|
||||
destination: {
|
||||
LinkItemDetailView(
|
||||
linkedItemObjectID: item.objectID,
|
||||
isPDF: item.isPDF
|
||||
)
|
||||
.background(ThemeManager.currentBgColor)
|
||||
}, label: {
|
||||
EmptyView()
|
||||
}
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
|
|
@ -57,7 +89,7 @@ struct GridCardNavigationLink: View {
|
|||
|
||||
@State private var scale = 1.0
|
||||
|
||||
let item: LinkedItem
|
||||
let item: Models.LibraryItem
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
|
||||
@Binding var isContextMenuOpen: Bool
|
||||
|
|
@ -65,12 +97,28 @@ struct GridCardNavigationLink: View {
|
|||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(destination: LinkItemDetailView(
|
||||
linkedItemObjectID: item.objectID,
|
||||
isPDF: item.isPDF
|
||||
)) {
|
||||
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
|
||||
}
|
||||
PresentationLink(
|
||||
transition: PresentationLinkTransition.slide(
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
|
||||
options:
|
||||
PresentationLinkTransition.Options(
|
||||
modalPresentationCapturesStatusBarAppearance: true
|
||||
))),
|
||||
destination: {
|
||||
LinkItemDetailView(
|
||||
linkedItemObjectID: item.objectID,
|
||||
isPDF: item.isPDF
|
||||
)
|
||||
}, label: {
|
||||
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
|
||||
}
|
||||
)
|
||||
// NavigationLink(destination: LinkItemDetailView(
|
||||
// linkedItemObjectID: item.objectID,
|
||||
// isPDF: item.isPDF
|
||||
// )) {
|
||||
//
|
||||
// }
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
////
|
||||
//// FollowingFetcher.swift
|
||||
////
|
||||
////
|
||||
//// Created by Jackson Harper on 11/16/23.
|
||||
////
|
||||
//
|
||||
// import Foundation
|
||||
//
|
||||
// import CoreData
|
||||
// import Models
|
||||
// import Services
|
||||
// import SwiftUI
|
||||
// import Utils
|
||||
// import Views
|
||||
//
|
||||
// @MainActor final class FollowingFetcher: NSObject, ObservableObject, LibraryItemFetcher {
|
||||
// var folder = "following"
|
||||
//
|
||||
// @Published var items = [Models.LibraryItem]()
|
||||
// var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { $items }
|
||||
//
|
||||
// private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
|
||||
//
|
||||
// var cursor: String?
|
||||
//
|
||||
// // These are used to make sure we handle search result
|
||||
// // responses in the right order
|
||||
// var searchIdx = 0
|
||||
// var receivedIdx = 0
|
||||
//
|
||||
// var syncCursor: String?
|
||||
//
|
||||
// func setItems(_: NSManagedObjectContext, _ items: [Models.LibraryItem]) {
|
||||
// self.items = items
|
||||
// }
|
||||
//
|
||||
// func loadCurrentViewer(dataService: DataService) async {
|
||||
// // Cache the viewer
|
||||
// if dataService.currentViewer == nil {
|
||||
// _ = try? await dataService.fetchViewer()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func loadLabels(dataService: DataService) async {
|
||||
// let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
|
||||
// fetchRequest.fetchLimit = 1
|
||||
//
|
||||
// if (try? dataService.viewContext.count(for: fetchRequest)) == 0 {
|
||||
// _ = try? await dataService.labels()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func syncItems(dataService: DataService) async {
|
||||
// let syncStart = Date.now
|
||||
// let lastSyncDate = dataService.lastItemSyncTime
|
||||
//
|
||||
// try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
//
|
||||
// let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
|
||||
// cursor: nil)
|
||||
//
|
||||
// syncCursor = syncResult?.cursor
|
||||
// if let syncResult = syncResult, syncResult.hasMore {
|
||||
// dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
|
||||
// // do nothing
|
||||
// }
|
||||
// } else {
|
||||
// dataService.lastItemSyncTime = syncStart
|
||||
// }
|
||||
//
|
||||
// // If possible start prefetching new pages in the background
|
||||
// if
|
||||
// let itemIDs = syncResult?.updatedItemIDs,
|
||||
// let username = dataService.currentViewer?.username,
|
||||
// !itemIDs.isEmpty
|
||||
// {
|
||||
// Task.detached(priority: .background) {
|
||||
// await dataService.prefetchPages(itemIDs: itemIDs, username: username)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
// let thisSearchIdx = searchIdx
|
||||
// searchIdx += 1
|
||||
//
|
||||
// if thisSearchIdx > 0, thisSearchIdx <= receivedIdx {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// let queryResult = try? await dataService.loadLinkedItems(
|
||||
// limit: 10,
|
||||
// searchQuery: searchQuery(filterState),
|
||||
// cursor: isRefresh ? nil : cursor
|
||||
// )
|
||||
//
|
||||
// let filter = LinkedItemFilter(rawValue: filterState.appliedFilter)
|
||||
//
|
||||
// if let queryResult = queryResult {
|
||||
// let newItems: [Models.LibraryItem] = {
|
||||
// var itemObjects = [Models.LibraryItem]()
|
||||
// dataService.viewContext.performAndWait {
|
||||
// itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? Models.LibraryItem }
|
||||
// }
|
||||
// return itemObjects
|
||||
// }()
|
||||
//
|
||||
// print("RESULTS OF SEARCH: ", newItems)
|
||||
//
|
||||
// if filterState.searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, filter?.allowLocalFetch ?? false {
|
||||
// updateFetchController(dataService: dataService, filterState: filterState)
|
||||
// } else {
|
||||
// // Don't use FRC for searching. Use server results directly.
|
||||
// if fetchedResultsController != nil {
|
||||
// fetchedResultsController = nil
|
||||
// setItems(dataService.viewContext, [])
|
||||
// }
|
||||
// setItems(dataService.viewContext, isRefresh ? newItems : items + newItems)
|
||||
// }
|
||||
//
|
||||
// receivedIdx = thisSearchIdx
|
||||
// cursor = queryResult.cursor
|
||||
//// if let username = dataService.currentViewer?.username {
|
||||
//// await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
|
||||
//// }
|
||||
// } else {
|
||||
// updateFetchController(dataService: dataService, filterState: filterState)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
// await withTaskGroup(of: Void.self) { group in
|
||||
// group.addTask { await self.loadCurrentViewer(dataService: dataService) }
|
||||
// group.addTask { await self.loadLabels(dataService: dataService) }
|
||||
// group.addTask { await self.syncItems(dataService: dataService) }
|
||||
// group.addTask { await self.updateFetchController(dataService: dataService, filterState: filterState) }
|
||||
// await group.waitForAll()
|
||||
// }
|
||||
//
|
||||
// let filter = LinkedItemFilter(rawValue: filterState.appliedFilter)
|
||||
// let shouldSearch = items.count < 1 || isRefresh && filter != LinkedItemFilter.downloaded
|
||||
// if shouldSearch {
|
||||
// await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
// } else {
|
||||
// updateFetchController(dataService: dataService, filterState: filterState)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
// let filter = LinkedItemFilter(rawValue: filterState.appliedFilter)
|
||||
// if filter != LinkedItemFilter.downloaded {
|
||||
// await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func fetchRequest(_ filterState: FetcherFilterState) -> NSFetchRequest<Models.LibraryItem> {
|
||||
// let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
//
|
||||
// var subPredicates = [NSPredicate]()
|
||||
//
|
||||
// let folderPredicate = NSPredicate(
|
||||
// format: "%K == %@", #keyPath(Models.LibraryItem.folder), folder
|
||||
// )
|
||||
// subPredicates.append(folderPredicate)
|
||||
//
|
||||
// if !filterState.selectedLabels.isEmpty {
|
||||
// var labelSubPredicates = [NSPredicate]()
|
||||
//
|
||||
// for label in filterState.selectedLabels {
|
||||
// labelSubPredicates.append(
|
||||
// NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count > 0")
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
|
||||
// }
|
||||
//
|
||||
// if !filterState.negatedLabels.isEmpty {
|
||||
// var labelSubPredicates = [NSPredicate]()
|
||||
//
|
||||
// for label in filterState.negatedLabels {
|
||||
// labelSubPredicates.append(
|
||||
// NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0")
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
|
||||
// }
|
||||
//
|
||||
// fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates)
|
||||
// fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest).sortDescriptors
|
||||
//
|
||||
// return fetchRequest
|
||||
// }
|
||||
//
|
||||
// private func updateFetchController(dataService: DataService, filterState: FetcherFilterState) {
|
||||
// fetchedResultsController = NSFetchedResultsController(
|
||||
// fetchRequest: fetchRequest(filterState),
|
||||
// managedObjectContext: dataService.viewContext,
|
||||
// sectionNameKeyPath: nil,
|
||||
// cacheName: nil
|
||||
// )
|
||||
//
|
||||
// guard let fetchedResultsController = fetchedResultsController else {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// fetchedResultsController.delegate = self
|
||||
// try? fetchedResultsController.performFetch()
|
||||
// setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? [])
|
||||
// }
|
||||
//
|
||||
// private func searchQuery(_ filterState: FetcherFilterState) -> String {
|
||||
// let sort = LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest
|
||||
//// var query = sort.queryString
|
||||
//
|
||||
// var query = "in:following \(sort.queryString)"
|
||||
//// if !queryContainsFilter(filterState), let filter = LinkedItemFilter(rawValue: filterState.appliedFilter) {
|
||||
//// query = "\(filter.queryString) \(sort.queryString)"
|
||||
//// }
|
||||
//
|
||||
// if !filterState.searchTerm.isEmpty {
|
||||
// query.append(" \(filterState.searchTerm)")
|
||||
// }
|
||||
//
|
||||
// if !filterState.selectedLabels.isEmpty {
|
||||
// query.append(" label:")
|
||||
// query.append(filterState.selectedLabels.compactMap { label in
|
||||
// if let name = label.name {
|
||||
// return "\"\(name)\""
|
||||
// }
|
||||
// return nil
|
||||
// }.joined(separator: ","))
|
||||
// }
|
||||
//
|
||||
// if !filterState.negatedLabels.isEmpty {
|
||||
// query.append(" !label:")
|
||||
// query.append(filterState.negatedLabels.compactMap { label in
|
||||
// if let name = label.name {
|
||||
// return "\"\(name)\""
|
||||
// }
|
||||
// return nil
|
||||
// }.joined(separator: ","))
|
||||
// }
|
||||
//
|
||||
// print("QUERY: `\(query)`")
|
||||
//
|
||||
// return query
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// extension FollowingFetcher: NSFetchedResultsControllerDelegate {
|
||||
// func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
// setItems(controller.managedObjectContext, controller.fetchedObjects as? [Models.LibraryItem] ?? [])
|
||||
// }
|
||||
// }
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
//
|
||||
// InboxFetcher.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/16/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor final class InboxFetcher: NSObject, ObservableObject, LibraryItemFetcher {
|
||||
var folder = "inbox"
|
||||
|
||||
@Published var items = [Models.LibraryItem]()
|
||||
var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { $items }
|
||||
|
||||
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
|
||||
|
||||
var cursor: String?
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
// responses in the right order
|
||||
var searchIdx = 0
|
||||
var receivedIdx = 0
|
||||
|
||||
var syncCursor: String?
|
||||
|
||||
func setItems(_: NSManagedObjectContext, _ items: [Models.LibraryItem]) {
|
||||
self.items = items
|
||||
}
|
||||
|
||||
func loadCurrentViewer(dataService: DataService) async {
|
||||
// Cache the viewer
|
||||
if dataService.currentViewer == nil {
|
||||
_ = try? await dataService.fetchViewer()
|
||||
}
|
||||
}
|
||||
|
||||
func loadLabels(dataService: DataService) async {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
if (try? dataService.viewContext.count(for: fetchRequest)) == 0 {
|
||||
_ = try? await dataService.labels()
|
||||
}
|
||||
}
|
||||
|
||||
func syncItems(dataService: DataService) async {
|
||||
let syncStart = Date.now
|
||||
let lastSyncDate = dataService.lastItemSyncTime
|
||||
|
||||
try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
|
||||
cursor: nil)
|
||||
|
||||
syncCursor = syncResult?.cursor
|
||||
if let syncResult = syncResult, syncResult.hasMore {
|
||||
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
|
||||
// do nothing
|
||||
}
|
||||
} else {
|
||||
dataService.lastItemSyncTime = syncStart
|
||||
}
|
||||
|
||||
// If possible start prefetching new pages in the background
|
||||
if
|
||||
let itemIDs = syncResult?.updatedItemIDs,
|
||||
let username = dataService.currentViewer?.username,
|
||||
!itemIDs.isEmpty
|
||||
{
|
||||
Task.detached(priority: .background) {
|
||||
await dataService.prefetchPages(itemIDs: itemIDs, username: username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
let thisSearchIdx = searchIdx
|
||||
searchIdx += 1
|
||||
|
||||
if thisSearchIdx > 0, thisSearchIdx <= receivedIdx {
|
||||
return
|
||||
}
|
||||
|
||||
let queryResult = try? await dataService.loadLinkedItems(
|
||||
limit: 10,
|
||||
searchQuery: searchQuery(filterState),
|
||||
cursor: isRefresh ? nil : cursor
|
||||
)
|
||||
|
||||
if let appliedFilter = filterState.appliedFilter, let queryResult = queryResult {
|
||||
let newItems: [Models.LibraryItem] = {
|
||||
var itemObjects = [Models.LibraryItem]()
|
||||
dataService.viewContext.performAndWait {
|
||||
itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? Models.LibraryItem }
|
||||
}
|
||||
return itemObjects
|
||||
}()
|
||||
|
||||
if filterState.searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, appliedFilter.allowLocalFetch {
|
||||
updateFetchController(dataService: dataService, filterState: filterState)
|
||||
} else {
|
||||
// Don't use FRC for searching. Use server results directly.
|
||||
if fetchedResultsController != nil {
|
||||
fetchedResultsController = nil
|
||||
setItems(dataService.viewContext, [])
|
||||
}
|
||||
setItems(dataService.viewContext, isRefresh ? newItems : items + newItems)
|
||||
}
|
||||
|
||||
receivedIdx = thisSearchIdx
|
||||
cursor = queryResult.cursor
|
||||
if let username = dataService.currentViewer?.username {
|
||||
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
|
||||
}
|
||||
} else {
|
||||
updateFetchController(dataService: dataService, filterState: filterState)
|
||||
}
|
||||
}
|
||||
|
||||
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
|
||||
group.addTask { await self.loadLabels(dataService: dataService) }
|
||||
group.addTask { await self.syncItems(dataService: dataService) }
|
||||
group.addTask { await self.updateFetchController(dataService: dataService, filterState: filterState) }
|
||||
await group.waitForAll()
|
||||
}
|
||||
|
||||
if let appliedFilter = filterState.appliedFilter {
|
||||
let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
|
||||
if shouldRemoteSearch {
|
||||
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
} else {
|
||||
updateFetchController(dataService: dataService, filterState: filterState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
if let appliedFilter = filterState.appliedFilter, appliedFilter.shouldRemoteSearch {
|
||||
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [Models.LibraryItem] {
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.fetchLimit = 25
|
||||
fetchRequest.predicate = predicate
|
||||
fetchRequest.sortDescriptors = [sort]
|
||||
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
private func fetchRequest(_ filterState: FetcherFilterState) -> NSFetchRequest<Models.LibraryItem> {
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
|
||||
var subPredicates = [NSPredicate]()
|
||||
|
||||
let folderPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(Models.LibraryItem.folder), folder
|
||||
)
|
||||
subPredicates.append(folderPredicate)
|
||||
|
||||
if let predicate = filterState.appliedFilter?.predicate {
|
||||
subPredicates.append(predicate)
|
||||
}
|
||||
|
||||
if !filterState.selectedLabels.isEmpty {
|
||||
var labelSubPredicates = [NSPredicate]()
|
||||
|
||||
for label in filterState.selectedLabels {
|
||||
labelSubPredicates.append(
|
||||
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count > 0")
|
||||
)
|
||||
}
|
||||
|
||||
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
|
||||
}
|
||||
|
||||
if !filterState.negatedLabels.isEmpty {
|
||||
var labelSubPredicates = [NSPredicate]()
|
||||
|
||||
for label in filterState.negatedLabels {
|
||||
labelSubPredicates.append(
|
||||
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0")
|
||||
)
|
||||
}
|
||||
|
||||
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
|
||||
}
|
||||
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates)
|
||||
fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest).sortDescriptors
|
||||
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
private func updateFetchController(dataService: DataService, filterState: FetcherFilterState) {
|
||||
fetchedResultsController = NSFetchedResultsController(
|
||||
fetchRequest: fetchRequest(filterState),
|
||||
managedObjectContext: dataService.viewContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
guard let fetchedResultsController = fetchedResultsController else {
|
||||
return
|
||||
}
|
||||
|
||||
fetchedResultsController.delegate = self
|
||||
try? fetchedResultsController.performFetch()
|
||||
setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? [])
|
||||
}
|
||||
|
||||
private func queryContainsFilter(_ filterState: FetcherFilterState) -> Bool {
|
||||
if filterState.searchTerm.contains("in:inbox") ||
|
||||
filterState.searchTerm.contains("in:all") ||
|
||||
filterState.searchTerm.contains("in:archive")
|
||||
{
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func searchQuery(_ filterState: FetcherFilterState) -> String {
|
||||
let sort = LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest
|
||||
var query = sort.queryString
|
||||
|
||||
if !queryContainsFilter(filterState), let queryString = filterState.appliedFilter?.filter {
|
||||
query = "\(queryString) \(sort.queryString)"
|
||||
}
|
||||
|
||||
if !filterState.searchTerm.isEmpty {
|
||||
query.append(" \(filterState.searchTerm)")
|
||||
}
|
||||
|
||||
if !filterState.selectedLabels.isEmpty {
|
||||
query.append(" label:")
|
||||
query.append(filterState.selectedLabels.compactMap { label in
|
||||
if let name = label.name {
|
||||
return "\"\(name)\""
|
||||
}
|
||||
return nil
|
||||
}.joined(separator: ","))
|
||||
}
|
||||
|
||||
if !filterState.negatedLabels.isEmpty {
|
||||
query.append(" !label:")
|
||||
query.append(filterState.negatedLabels.compactMap { label in
|
||||
if let name = label.name {
|
||||
return "\"\(name)\""
|
||||
}
|
||||
return nil
|
||||
}.joined(separator: ","))
|
||||
}
|
||||
|
||||
print("QUERY: `\(query)`")
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
extension InboxFetcher: NSFetchedResultsControllerDelegate {
|
||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
setItems(controller.managedObjectContext, controller.fetchedObjects as? [Models.LibraryItem] ?? [])
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ struct LibraryFeatureCardNavigationLink: View {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
let item: LinkedItem
|
||||
let item: Models.LibraryItem
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
@State var showFeatureActions = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
|
||||
// import Introspect
|
||||
// import Models
|
||||
// import Services
|
||||
// import SwiftUI
|
||||
// import Views
|
||||
//
|
||||
// @MainActor final class FilterSelectorViewModel: NSObject, ObservableObject {
|
||||
// @Published var isLoading = false
|
||||
// @Published var errorMessage: String = ""
|
||||
// @Published var showErrorMessage: Bool = false
|
||||
//
|
||||
// func error(_ msg: String) {
|
||||
// errorMessage = msg
|
||||
// showErrorMessage = true
|
||||
// isLoading = false
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// struct FilterSelectorView: View {
|
||||
// @ObservedObject var viewModel: HomeFeedViewModel
|
||||
// @ObservedObject var filterViewModel = FilterByLabelsViewModel()
|
||||
// @EnvironmentObject var dataService: DataService
|
||||
// @Environment(\.dismiss) private var dismiss
|
||||
//
|
||||
// @State var showLabelsSheet = false
|
||||
//
|
||||
// init(viewModel: HomeFeedViewModel) {
|
||||
// self.viewModel = viewModel
|
||||
// }
|
||||
//
|
||||
// var body: some View {
|
||||
// Group {
|
||||
// #if os(iOS)
|
||||
// List {
|
||||
// innerBody
|
||||
// }
|
||||
// .listStyle(.grouped)
|
||||
// #elseif os(macOS)
|
||||
// List {
|
||||
// innerBody
|
||||
// }
|
||||
// .listStyle(.plain)
|
||||
// #endif
|
||||
// }
|
||||
// #if os(iOS)
|
||||
// .navigationBarTitle("Library")
|
||||
// .navigationBarTitleDisplayMode(.inline)
|
||||
// .navigationBarItems(trailing: doneButton)
|
||||
// #endif
|
||||
// }
|
||||
//
|
||||
// private var innerBody: some View {
|
||||
// Group {
|
||||
// Section {
|
||||
// ForEach(LinkedItemFilter.allCases, id: \.self) { filter in
|
||||
// HStack {
|
||||
// Text(filter.displayName)
|
||||
// .foregroundColor(filterState.appliedFilter == filter.rawValue ? Color.blue : Color.appTextDefault)
|
||||
// Spacer()
|
||||
// if filterState.appliedFilter == filter.rawValue {
|
||||
// Image(systemName: "checkmark")
|
||||
// .foregroundColor(Color.blue)
|
||||
// }
|
||||
// }
|
||||
// .contentShape(Rectangle())
|
||||
// .onTapGesture {
|
||||
// filterState.appliedFilter = filter.rawValue
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Section("Labels") {
|
||||
// Button(
|
||||
// action: {
|
||||
// showLabelsSheet = true
|
||||
// },
|
||||
// label: {
|
||||
// HStack {
|
||||
// Text("Select Labels (\(filterState.selectedLabels.count))")
|
||||
// Spacer()
|
||||
// Image(systemName: "chevron.right")
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// .sheet(isPresented: $showLabelsSheet) {
|
||||
// FilterByLabelsView(
|
||||
// initiallySelected: filterState.selectedLabels,
|
||||
// initiallyNegated: filterState.negatedLabels
|
||||
// ) {
|
||||
// self.filterState.selectedLabels = $0
|
||||
// self.filterState.negatedLabels = $1
|
||||
// }
|
||||
// }
|
||||
// .task {
|
||||
// await filterViewModel.loadLabels(
|
||||
// dataService: dataService,
|
||||
// initiallySelectedLabels: filterState.selectedLabels,
|
||||
// initiallyNegatedLabels: filterState.negatedLabels
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func isNegated(_ label: LinkedItemLabel) -> Bool {
|
||||
// filterViewModel.negatedLabels.contains(where: { $0.id == label.id })
|
||||
// }
|
||||
//
|
||||
// func isSelected(_ label: LinkedItemLabel) -> Bool {
|
||||
// filterViewModel.selectedLabels.contains(where: { $0.id == label.id })
|
||||
// }
|
||||
//
|
||||
// var doneButton: some View {
|
||||
// Button(
|
||||
// action: { dismiss() },
|
||||
// label: { Text("Done") }
|
||||
// )
|
||||
// .disabled(viewModel.isLoading)
|
||||
// }
|
||||
// }
|
||||
|
|
@ -43,19 +43,25 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
@State private var selection = Set<String>()
|
||||
@ObservedObject var filterState = FetcherFilterState(
|
||||
appliedFilterName: UserDefaults.standard.string(forKey: UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) ??
|
||||
LinkedItemFilter.inbox.rawValue
|
||||
)
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
Task { await viewModel.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var showFeatureCards: Bool {
|
||||
viewModel.listConfig.hasFeatureCards &&
|
||||
!viewModel.hideFeatureSection &&
|
||||
viewModel.items.count > 0 &&
|
||||
viewModel.searchTerm.isEmpty &&
|
||||
viewModel.selectedLabels.isEmpty &&
|
||||
viewModel.negatedLabels.isEmpty &&
|
||||
viewModel.appliedFilterName == "inbox"
|
||||
viewModel.fetcher.items.count > 0 &&
|
||||
filterState.searchTerm.isEmpty &&
|
||||
filterState.selectedLabels.isEmpty &&
|
||||
filterState.negatedLabels.isEmpty
|
||||
// MERGE TODO
|
||||
// &&
|
||||
// viewModel.appliedFilterName == "inbox"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -66,30 +72,34 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
isEditMode: $isEditMode,
|
||||
selection: $selection,
|
||||
viewModel: viewModel,
|
||||
filterState: filterState,
|
||||
showFeatureCards: showFeatureCards
|
||||
)
|
||||
.refreshable {
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.searchTerm) { _ in
|
||||
.onChange(of: filterState.searchTerm) { _ in
|
||||
// Maybe we should debounce this, but
|
||||
// it feels like it works ok without
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.selectedLabels) { _ in
|
||||
.onChange(of: filterState.selectedLabels) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.negatedLabels) { _ in
|
||||
.onChange(of: filterState.negatedLabels) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.appliedFilter) { _ in
|
||||
.onChange(of: filterState.appliedFilter) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.onChange(of: viewModel.appliedSort) { _ in
|
||||
.onChange(of: filterState.appliedSort) { _ in
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
|
||||
ApplyLabelsView(mode: .item(item), onSave: nil)
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { _ in
|
||||
NavigationView {
|
||||
BriefingView()
|
||||
}
|
||||
// ApplyLabelsView(mode: .item(item), onSave: nil)
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderTitleEdit) { item in
|
||||
LinkedItemMetadataEditView(item: item)
|
||||
|
|
@ -115,7 +125,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.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 }
|
||||
guard let linkedItem = dataService.viewContext.object(with: objectID) as? Models.LibraryItem else { return }
|
||||
viewModel.pushFeedItem(item: linkedItem)
|
||||
viewModel.selectedItem = linkedItem
|
||||
viewModel.linkIsActive = true
|
||||
|
|
@ -125,10 +135,10 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
if let deepLink = DeepLink.make(from: url) {
|
||||
switch deepLink {
|
||||
case let .search(query):
|
||||
viewModel.searchTerm = query
|
||||
filterState.searchTerm = query
|
||||
case let .savedSearch(named):
|
||||
if let filter = viewModel.findFilter(dataService, named: named) {
|
||||
viewModel.appliedFilter = filter
|
||||
filterState.appliedFilter = filter
|
||||
}
|
||||
case let .webAppLinkRequest(requestID):
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
|
|
@ -153,7 +163,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
.task {
|
||||
if viewModel.items.isEmpty {
|
||||
if viewModel.fetcher.items.isEmpty {
|
||||
loadItems(isRefresh: false)
|
||||
}
|
||||
}
|
||||
|
|
@ -165,10 +175,9 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
ToolbarItem(placement: .barLeading) {
|
||||
VStack(alignment: .leading) {
|
||||
let showDate = isListScrolled && !listTitle.isEmpty
|
||||
if let title = viewModel.appliedFilter?.name {
|
||||
if let title = filterState.appliedFilter?.name {
|
||||
Text(title)
|
||||
.font(Font.system(size: showDate ? 10 : 18, weight: .semibold))
|
||||
|
||||
if showDate, prefersListLayout, isListScrolled || !showFeatureCards {
|
||||
Text(listTitle)
|
||||
.font(Font.system(size: 15, weight: .regular))
|
||||
|
|
@ -263,6 +272,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var isEditMode: EditMode
|
||||
@Binding var selection: Set<String>
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
@ObservedObject var filterState: FetcherFilterState
|
||||
|
||||
let showFeatureCards: Bool
|
||||
|
||||
|
|
@ -292,18 +302,23 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
isEditMode: $isEditMode,
|
||||
selection: $selection,
|
||||
viewModel: viewModel,
|
||||
filterState: filterState,
|
||||
showFeatureCards: showFeatureCards
|
||||
)
|
||||
} else {
|
||||
HomeFeedGridView(viewModel: viewModel, isListScrolled: $isListScrolled)
|
||||
HomeFeedGridView(
|
||||
viewModel: viewModel,
|
||||
filterState: filterState,
|
||||
isListScrolled: $isListScrolled
|
||||
)
|
||||
}
|
||||
}.sheet(isPresented: $viewModel.showLabelsSheet) {
|
||||
FilterByLabelsView(
|
||||
initiallySelected: viewModel.selectedLabels,
|
||||
initiallyNegated: viewModel.negatedLabels
|
||||
initiallySelected: filterState.selectedLabels,
|
||||
initiallyNegated: filterState.negatedLabels
|
||||
) {
|
||||
self.viewModel.selectedLabels = $0
|
||||
self.viewModel.negatedLabels = $1
|
||||
self.filterState.selectedLabels = $0
|
||||
self.filterState.negatedLabels = $1
|
||||
}
|
||||
}
|
||||
.popup(isPresented: $viewModel.showSnackbar) {
|
||||
|
|
@ -344,6 +359,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
@Binding var selection: Set<String>
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
@ObservedObject var filterState: FetcherFilterState
|
||||
|
||||
let showFeatureCards: Bool
|
||||
|
||||
|
|
@ -351,20 +367,20 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
GeometryReader { reader in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
if viewModel.searchTerm.count > 0 {
|
||||
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
|
||||
viewModel.searchTerm = ""
|
||||
if filterState.searchTerm.count > 0 {
|
||||
TextChipButton.makeSearchFilterButton(title: filterState.searchTerm) {
|
||||
filterState.searchTerm = ""
|
||||
}.frame(maxWidth: reader.size.width * 0.66)
|
||||
} else {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(viewModel.filters) { filter in
|
||||
Button(filter.name, action: { viewModel.appliedFilter = filter })
|
||||
Button(filter.name, action: { filterState.appliedFilter = filter })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: viewModel.appliedFilter?.name ?? "-",
|
||||
title: filterState.appliedFilter?.name ?? "-",
|
||||
color: .systemGray6
|
||||
)
|
||||
}
|
||||
|
|
@ -373,25 +389,25 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Menu(
|
||||
content: {
|
||||
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
|
||||
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
|
||||
Button(sort.displayName, action: { filterState.appliedSort = sort.rawValue })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort",
|
||||
title: LinkedItemSort(rawValue: filterState.appliedSort)?.displayName ?? "Sort",
|
||||
color: .systemGray6
|
||||
)
|
||||
}
|
||||
)
|
||||
TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true })
|
||||
ForEach(viewModel.selectedLabels, id: \.self) { label in
|
||||
ForEach(filterState.selectedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
|
||||
viewModel.selectedLabels.removeAll { $0.id == label.id }
|
||||
filterState.selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.negatedLabels, id: \.self) { label in
|
||||
ForEach(filterState.negatedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
|
||||
viewModel.negatedLabels.removeAll { $0.id == label.id }
|
||||
filterState.negatedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
|
@ -403,7 +419,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.dynamicTypeSize(.small ... .accessibility1)
|
||||
}
|
||||
|
||||
func menuItems(for item: LinkedItem) -> some View {
|
||||
func menuItems(for item: Models.LibraryItem) -> some View {
|
||||
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
|
||||
}
|
||||
|
||||
|
|
@ -509,9 +525,9 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
static func reduce(value _: inout CGPoint, nextValue _: () -> CGPoint) {}
|
||||
}
|
||||
|
||||
@State var topItem: LinkedItem?
|
||||
@State var topItem: Models.LibraryItem?
|
||||
|
||||
func setTopItem(_ item: LinkedItem) {
|
||||
func setTopItem(_ item: Models.LibraryItem) {
|
||||
if let date = item.savedAt, let daysAgo = Calendar.current.dateComponents([.day], from: date, to: Date()).day {
|
||||
if daysAgo < 1 {
|
||||
let formatter = DateFormatter()
|
||||
|
|
@ -561,7 +577,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.listRowSeparator(.hidden, edges: .all)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset))
|
||||
|
||||
if let appliedFilter = viewModel.appliedFilter,
|
||||
if let appliedFilter = filterState.appliedFilter,
|
||||
networkMonitor.status == .disconnected,
|
||||
!appliedFilter.allowLocalFetch
|
||||
{
|
||||
|
|
@ -593,7 +609,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
|
||||
ForEach(Array(viewModel.items.enumerated()), id: \.1.unwrappedID) { _, item in
|
||||
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
isInMultiSelectMode: viewModel.isInMultiSelectMode,
|
||||
|
|
@ -642,41 +658,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
|
||||
func dateSummaryCard(_: Date) -> some View {
|
||||
VStack(alignment: .center, spacing: 15) {
|
||||
Text("3 articles saved today")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.font(.body)
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: 0) {
|
||||
Button(action: {}, label: {
|
||||
Text("Archive all")
|
||||
.font(Font.system(size: 14))
|
||||
.padding(.horizontal, 10)
|
||||
})
|
||||
.frame(height: 30)
|
||||
.background(Color.blue)
|
||||
Button(action: {}, label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 10, height: 10)
|
||||
.padding(.leading, 7.5)
|
||||
.padding(.trailing, 7.5)
|
||||
.foregroundColor(Color.white)
|
||||
})
|
||||
.frame(height: 30)
|
||||
.background(Color(hex: "345BB8"))
|
||||
}
|
||||
.cornerRadius(2.5)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(15)
|
||||
}
|
||||
|
||||
func swipeActionButton(action: SwipeAction, item: LinkedItem) -> AnyView {
|
||||
func swipeActionButton(action: SwipeAction, item: Models.LibraryItem) -> AnyView {
|
||||
switch action {
|
||||
case .pin:
|
||||
let isPinned = item.labels?.allObjects.first { ($0 as? LinkedItemLabel)?.name == "Pinned" } != nil
|
||||
|
|
@ -717,7 +699,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
// viewModel.addLabel(dataService: dataService, item: item, label: "Inbox", color)
|
||||
},
|
||||
label: {
|
||||
Label("Move to Inbox", systemImage: "tray.fill")
|
||||
Label(title: { Text("Move to Library") },
|
||||
icon: { Image.tabLibrary })
|
||||
}
|
||||
).tint(Color(hex: "#0A84FF")))
|
||||
}
|
||||
|
|
@ -731,9 +714,11 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@State var isContextMenuOpen = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
@ObservedObject var filterState: FetcherFilterState
|
||||
|
||||
@Binding var isListScrolled: Bool
|
||||
|
||||
func contextMenuActionHandler(item: LinkedItem, action: GridCardAction) {
|
||||
func contextMenuActionHandler(item: Models.LibraryItem, action: GridCardAction) {
|
||||
switch action {
|
||||
case .viewHighlights:
|
||||
viewModel.itemForHighlightsView = item
|
||||
|
|
@ -749,27 +734,27 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
Task { await viewModel.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var filtersHeader: some View {
|
||||
GeometryReader { reader in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
if viewModel.searchTerm.count > 0 {
|
||||
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
|
||||
viewModel.searchTerm = ""
|
||||
if filterState.searchTerm.count > 0 {
|
||||
TextChipButton.makeSearchFilterButton(title: filterState.searchTerm) {
|
||||
filterState.searchTerm = ""
|
||||
}.frame(maxWidth: reader.size.width * 0.66)
|
||||
} else {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(viewModel.filters, id: \.self) { filter in
|
||||
Button(filter.name, action: { viewModel.appliedFilter = filter })
|
||||
Button(filter.name, action: { filterState.appliedFilter = filter })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: viewModel.appliedFilter?.name ?? "-",
|
||||
title: filterState.appliedFilter?.name ?? "-",
|
||||
color: .systemGray6
|
||||
)
|
||||
}
|
||||
|
|
@ -778,25 +763,25 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Menu(
|
||||
content: {
|
||||
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
|
||||
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
|
||||
Button(sort.displayName, action: { filterState.appliedSort = sort.rawValue })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort",
|
||||
title: LinkedItemSort(rawValue: filterState.appliedSort)?.displayName ?? "Sort",
|
||||
color: .systemGray6
|
||||
)
|
||||
}
|
||||
)
|
||||
TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true })
|
||||
ForEach(viewModel.selectedLabels, id: \.self) { label in
|
||||
ForEach(filterState.selectedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
|
||||
viewModel.selectedLabels.removeAll { $0.id == label.id }
|
||||
filterState.selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.negatedLabels, id: \.self) { label in
|
||||
ForEach(filterState.negatedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
|
||||
viewModel.negatedLabels.removeAll { $0.id == label.id }
|
||||
filterState.negatedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
|
@ -832,7 +817,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) {
|
||||
ForEach(viewModel.items) { item in
|
||||
ForEach(viewModel.fetcher.items) { item in
|
||||
GridCardNavigationLink(
|
||||
item: item,
|
||||
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
|
||||
|
|
@ -860,7 +845,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
|
||||
if viewModel.items.isEmpty, viewModel.isLoading {
|
||||
if viewModel.fetcher.items.isEmpty, viewModel.isLoading {
|
||||
LoadingSection()
|
||||
}
|
||||
}
|
||||
|
|
@ -896,7 +881,7 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey {
|
|||
#endif
|
||||
|
||||
struct LinkDestination: View {
|
||||
let selectedItem: LinkedItem?
|
||||
let selectedItem: Models.LibraryItem?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import Views
|
|||
@EnvironmentObject var audioController: AudioController
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
|
||||
@State private var itemToRemove: LinkedItem?
|
||||
@State private var itemToRemove: Models.LibraryItem?
|
||||
@State private var confirmationShown = false
|
||||
@State private var presentProfileSheet = false
|
||||
@State private var addLinkPresented = false
|
||||
|
|
@ -29,7 +29,7 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func menuItems(_ item: LinkedItem) -> some View {
|
||||
func menuItems(_ item: Models.LibraryItem) -> some View {
|
||||
Group {
|
||||
Button(
|
||||
action: { viewModel.itemUnderTitleEdit = item },
|
||||
|
|
|
|||
|
|
@ -5,35 +5,30 @@ import SwiftUI
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
|
||||
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
|
||||
var currentDetailViewModel: LinkItemDetailViewModel?
|
||||
|
||||
private var fetchedResultsController: NSFetchedResultsController<LinkedItem>?
|
||||
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
|
||||
|
||||
@Published var items = [LinkedItem]()
|
||||
@Published var isLoading = false
|
||||
@Published var showPushNotificationPrimer = false
|
||||
@Published var itemUnderLabelEdit: LinkedItem?
|
||||
@Published var itemUnderTitleEdit: LinkedItem?
|
||||
@Published var itemForHighlightsView: LinkedItem?
|
||||
@Published var searchTerm = ""
|
||||
@Published var scopeSelection = 0
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var negatedLabels = [LinkedItemLabel]()
|
||||
@Published var itemUnderLabelEdit: Models.LibraryItem?
|
||||
@Published var itemUnderTitleEdit: Models.LibraryItem?
|
||||
@Published var itemForHighlightsView: Models.LibraryItem?
|
||||
@Published var snoozePresented = false
|
||||
@Published var itemToSnoozeID: String?
|
||||
@Published var linkRequest: LinkRequest?
|
||||
@Published var showLoadingBar = false
|
||||
@Published var isInMultiSelectMode = false
|
||||
@Published var appliedSort = LinkedItemSort.newest.rawValue
|
||||
|
||||
@Published var selectedLinkItem: NSManagedObjectID? // used by mac app only
|
||||
@Published var selectedItem: LinkedItem?
|
||||
@Published var selectedItem: Models.LibraryItem?
|
||||
@Published var linkIsActive = false
|
||||
|
||||
@Published var showLabelsSheet = false
|
||||
@Published var showFiltersModal = false
|
||||
@Published var featureItems = [LinkedItem]()
|
||||
@Published var showCommunityModal = false
|
||||
@Published var featureItems = [Models.LibraryItem]()
|
||||
|
||||
@Published var listConfig: LibraryListConfig
|
||||
|
||||
|
|
@ -41,11 +36,6 @@ import Views
|
|||
@Published var snackbarOperation: SnackbarOperation?
|
||||
|
||||
@Published var filters = [InternalFilter]()
|
||||
@Published var appliedFilter: InternalFilter? {
|
||||
didSet {
|
||||
appliedFilterName = appliedFilter?.name.lowercased() ?? "inbox"
|
||||
}
|
||||
}
|
||||
|
||||
var cursor: String?
|
||||
|
||||
|
|
@ -57,19 +47,16 @@ import Views
|
|||
var syncCursor: String?
|
||||
|
||||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilterName = "inbox"
|
||||
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
|
||||
|
||||
init(listConfig: LibraryListConfig) {
|
||||
let fetcher: LibraryItemFetcher
|
||||
|
||||
init(fetcher: LibraryItemFetcher, listConfig: LibraryListConfig) {
|
||||
self.fetcher = fetcher
|
||||
self.listConfig = listConfig
|
||||
super.init()
|
||||
}
|
||||
|
||||
func setItems(_ context: NSManagedObjectContext, _ items: [LinkedItem]) {
|
||||
self.items = items
|
||||
updateFeatureFilter(context: context, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
}
|
||||
|
||||
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
|
||||
if let filter = filter {
|
||||
Task {
|
||||
|
|
@ -86,21 +73,22 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func itemAppeared(item: LinkedItem, dataService: DataService) async {
|
||||
func itemAppeared(item: Models.LibraryItem, dataService _: DataService) async {
|
||||
if isLoading { return }
|
||||
let itemIndex = items.firstIndex(where: { $0.id == item.id })
|
||||
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
|
||||
let itemIndex = fetcher.items.firstIndex(where: { $0.id == item.id })
|
||||
let thresholdIndex = fetcher.items.index(fetcher.items.endIndex, offsetBy: -5)
|
||||
|
||||
// Check if user has scrolled to the last five items in the list
|
||||
// Make sure we aren't currently loading though, as this would get triggered when the first set
|
||||
// of items are presented to the user.
|
||||
if let itemIndex = itemIndex, itemIndex > thresholdIndex {
|
||||
await loadMoreItems(dataService: dataService, isRefresh: false)
|
||||
}
|
||||
// if let itemIndex = itemIndex, itemIndex > thresholdIndex {
|
||||
// await loadMoreItems(dataService: dataService, isRefresh: false)
|
||||
// }
|
||||
}
|
||||
|
||||
func pushFeedItem(item: LinkedItem) {
|
||||
items.insert(item, at: 0)
|
||||
func pushFeedItem(item _: Models.LibraryItem) {
|
||||
/// TODO: jackson
|
||||
// fetcher.items.insert(item, at: 0)
|
||||
}
|
||||
|
||||
func loadCurrentViewer(dataService: DataService) async {
|
||||
|
|
@ -139,110 +127,19 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func updateFilters(newFilters: [InternalFilter]) {
|
||||
filters = newFilters.sorted(by: { $0.position < $1.position }) + [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter]
|
||||
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
|
||||
appliedFilter = newFilter
|
||||
}
|
||||
func updateFilters(newFilters _: [InternalFilter]) {
|
||||
// filters = newFilters.sorted(by: { $0.position < $1.position }) + [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter]
|
||||
// if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
|
||||
// appliedFilter = newFilter
|
||||
// }
|
||||
}
|
||||
|
||||
func syncItems(dataService: DataService) async {
|
||||
let syncStart = Date.now
|
||||
let lastSyncDate = dataService.lastItemSyncTime
|
||||
|
||||
try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
|
||||
cursor: nil)
|
||||
|
||||
syncCursor = syncResult?.cursor
|
||||
if let syncResult = syncResult, syncResult.hasMore {
|
||||
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
|
||||
// Set isLoading to false here
|
||||
self.isLoading = false
|
||||
}
|
||||
} else {
|
||||
dataService.lastItemSyncTime = syncStart
|
||||
}
|
||||
|
||||
// If possible start prefetching new pages in the background
|
||||
if
|
||||
let itemIDs = syncResult?.updatedItemIDs,
|
||||
let username = dataService.currentViewer?.username,
|
||||
!itemIDs.isEmpty
|
||||
{
|
||||
Task.detached(priority: .background) {
|
||||
await dataService.prefetchPages(itemIDs: itemIDs, username: username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadSearchQuery(dataService: DataService, isRefresh: Bool) async {
|
||||
let thisSearchIdx = searchIdx
|
||||
searchIdx += 1
|
||||
|
||||
if thisSearchIdx > 0, thisSearchIdx <= receivedIdx {
|
||||
return
|
||||
}
|
||||
|
||||
let queryResult = try? await dataService.loadLinkedItems(
|
||||
limit: 10,
|
||||
searchQuery: searchQuery,
|
||||
cursor: isRefresh ? nil : cursor
|
||||
)
|
||||
|
||||
if let appliedFilter = appliedFilter, let queryResult = queryResult {
|
||||
let newItems: [LinkedItem] = {
|
||||
var itemObjects = [LinkedItem]()
|
||||
dataService.viewContext.performAndWait {
|
||||
itemObjects = queryResult.itemIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItem }
|
||||
}
|
||||
return itemObjects
|
||||
}()
|
||||
|
||||
if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, appliedFilter.predicate != nil {
|
||||
updateFetchController(dataService: dataService)
|
||||
} else {
|
||||
// Don't use FRC for searching. Use server results directly.
|
||||
if fetchedResultsController != nil {
|
||||
fetchedResultsController = nil
|
||||
setItems(dataService.viewContext, [])
|
||||
}
|
||||
setItems(dataService.viewContext, isRefresh ? newItems : items + newItems)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
receivedIdx = thisSearchIdx
|
||||
cursor = queryResult.cursor
|
||||
if let username = dataService.currentViewer?.username {
|
||||
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
|
||||
}
|
||||
} else {
|
||||
updateFetchController(dataService: dataService)
|
||||
}
|
||||
}
|
||||
|
||||
func loadItems(dataService: DataService, isRefresh: Bool) async {
|
||||
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
isLoading = true
|
||||
showLoadingBar = true
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
|
||||
group.addTask { await self.loadLabels(dataService: dataService) }
|
||||
group.addTask { await self.loadFilters(dataService: dataService) }
|
||||
group.addTask { await self.syncItems(dataService: dataService) }
|
||||
group.addTask { await self.updateFetchController(dataService: dataService) }
|
||||
await group.waitForAll()
|
||||
}
|
||||
|
||||
if let appliedFilter = appliedFilter {
|
||||
let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
|
||||
if shouldRemoteSearch {
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
} else {
|
||||
updateFetchController(dataService: dataService)
|
||||
}
|
||||
}
|
||||
// group.addTask { await self.loadFilters(dataService: dataService) }
|
||||
await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
|
||||
|
|
@ -250,20 +147,18 @@ import Views
|
|||
showLoadingBar = false
|
||||
}
|
||||
|
||||
func loadMoreItems(dataService: DataService, isRefresh: Bool) async {
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
isLoading = true
|
||||
showLoadingBar = true
|
||||
|
||||
if let appliedFilter, appliedFilter.shouldRemoteSearch {
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
}
|
||||
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
|
||||
isLoading = false
|
||||
showLoadingBar = false
|
||||
}
|
||||
|
||||
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [LinkedItem] {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [Models.LibraryItem] {
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.fetchLimit = 25
|
||||
fetchRequest.predicate = predicate
|
||||
fetchRequest.sortDescriptors = [sort]
|
||||
|
|
@ -271,62 +166,6 @@ import Views
|
|||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
|
||||
var subPredicates = [NSPredicate]()
|
||||
|
||||
if let predicate = appliedFilter?.predicate {
|
||||
subPredicates.append(predicate)
|
||||
}
|
||||
|
||||
if !selectedLabels.isEmpty {
|
||||
var labelSubPredicates = [NSPredicate]()
|
||||
|
||||
for label in selectedLabels {
|
||||
labelSubPredicates.append(
|
||||
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count > 0")
|
||||
)
|
||||
}
|
||||
|
||||
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
|
||||
}
|
||||
|
||||
if !negatedLabels.isEmpty {
|
||||
var labelSubPredicates = [NSPredicate]()
|
||||
|
||||
for label in negatedLabels {
|
||||
labelSubPredicates.append(
|
||||
NSPredicate(format: "SUBQUERY(labels, $label, $label.id == \"\(label.unwrappedID)\").@count == 0")
|
||||
)
|
||||
}
|
||||
|
||||
subPredicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: labelSubPredicates))
|
||||
}
|
||||
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subPredicates)
|
||||
fetchRequest.sortDescriptors = (LinkedItemSort(rawValue: appliedSort) ?? .newest).sortDescriptors
|
||||
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
private func updateFetchController(dataService: DataService) {
|
||||
fetchedResultsController = NSFetchedResultsController(
|
||||
fetchRequest: fetchRequest,
|
||||
managedObjectContext: dataService.viewContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
guard let fetchedResultsController = fetchedResultsController else {
|
||||
return
|
||||
}
|
||||
|
||||
fetchedResultsController.delegate = self
|
||||
try? fetchedResultsController.performFetch()
|
||||
setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? [])
|
||||
}
|
||||
|
||||
func snackbar(_ message: String, undoAction: SnackbarUndoAction? = nil) {
|
||||
snackbarOperation = SnackbarOperation(message: message, undoAction: undoAction)
|
||||
showSnackbar = true
|
||||
|
|
@ -362,7 +201,7 @@ import Views
|
|||
return nil
|
||||
}
|
||||
|
||||
func addLabel(dataService: DataService, item: LinkedItem, label: String, color: String) {
|
||||
func addLabel(dataService: DataService, item: Models.LibraryItem, label: String, color: String) {
|
||||
if let label = getOrCreateLabel(dataService: dataService, named: "Pinned", color: color) {
|
||||
let existingLabels = item.labels?.allObjects.compactMap { $0 as? LinkedItemLabel } ?? []
|
||||
dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(existingLabels + [label]) as NSSet))
|
||||
|
|
@ -372,7 +211,7 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func removeLabel(dataService: DataService, item: LinkedItem, named: String) {
|
||||
func removeLabel(dataService: DataService, item: Models.LibraryItem, named: String) {
|
||||
let labels = item.labels?
|
||||
.filter { ($0 as? LinkedItemLabel)?.name != named }
|
||||
.compactMap { $0 as? LinkedItemLabel } ?? []
|
||||
|
|
@ -380,25 +219,25 @@ import Views
|
|||
item.update(inContext: dataService.viewContext)
|
||||
}
|
||||
|
||||
func pinItem(dataService: DataService, item: LinkedItem) {
|
||||
func pinItem(dataService: DataService, item: Models.LibraryItem) {
|
||||
addLabel(dataService: dataService, item: item, label: "Pinned", color: "#0A84FF")
|
||||
if featureFilter == FeaturedItemFilter.pinned.rawValue {
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func unpinItem(dataService: DataService, item: LinkedItem) {
|
||||
func unpinItem(dataService: DataService, item: Models.LibraryItem) {
|
||||
removeLabel(dataService: dataService, item: item, named: "Pinned")
|
||||
if featureFilter == FeaturedItemFilter.pinned.rawValue {
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(dataService: DataService, item: LinkedItem) {
|
||||
func markRead(dataService: DataService, item: Models.LibraryItem) {
|
||||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 100, anchorIndex: 0, force: true)
|
||||
}
|
||||
|
||||
func markUnread(dataService: DataService, item: LinkedItem) {
|
||||
func markUnread(dataService: DataService, item: Models.LibraryItem) {
|
||||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0, force: true)
|
||||
}
|
||||
|
||||
|
|
@ -420,55 +259,4 @@ import Views
|
|||
func findFilter(_: DataService, named: String) -> InternalFilter? {
|
||||
filters.first(where: { $0.name == named })
|
||||
}
|
||||
|
||||
private var queryContainsFilter: Bool {
|
||||
if searchTerm.contains("in:inbox") || searchTerm.contains("in:all") || searchTerm.contains("in:archive") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private var searchQuery: String {
|
||||
let sort = LinkedItemSort(rawValue: appliedSort) ?? .newest
|
||||
var query = sort.queryString
|
||||
|
||||
if !queryContainsFilter, let filter = appliedFilter?.filter {
|
||||
query = "\(filter) \(sort.queryString)"
|
||||
}
|
||||
|
||||
if !searchTerm.isEmpty {
|
||||
query.append(" \(searchTerm)")
|
||||
}
|
||||
|
||||
if !selectedLabels.isEmpty {
|
||||
query.append(" label:")
|
||||
query.append(selectedLabels.compactMap { label in
|
||||
if let name = label.name {
|
||||
return "\"\(name)\""
|
||||
}
|
||||
return nil
|
||||
}.joined(separator: ","))
|
||||
}
|
||||
|
||||
if !negatedLabels.isEmpty {
|
||||
query.append(" !label:")
|
||||
query.append(negatedLabels.compactMap { label in
|
||||
if let name = label.name {
|
||||
return "\"\(name)\""
|
||||
}
|
||||
return nil
|
||||
}.joined(separator: ","))
|
||||
}
|
||||
|
||||
print("QUERY: `\(query)`")
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeFeedViewModel: NSFetchedResultsControllerDelegate {
|
||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
setItems(controller.managedObjectContext, controller.fetchedObjects as? [LinkedItem] ?? [])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/16/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
@MainActor
|
||||
class FetcherFilterState: ObservableObject {
|
||||
@Published var searchTerm = ""
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var negatedLabels = [LinkedItemLabel]()
|
||||
|
||||
@Published var appliedSort = LinkedItemSort.newest.rawValue
|
||||
|
||||
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilterName = "inbox"
|
||||
@Published var appliedFilter: InternalFilter? {
|
||||
didSet {
|
||||
appliedFilterName = appliedFilter?.name.lowercased() ?? "inbox"
|
||||
}
|
||||
}
|
||||
|
||||
init(appliedFilterName: String) {
|
||||
self.appliedFilterName = appliedFilterName
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol LibraryItemFetcher {
|
||||
var folder: String { get }
|
||||
|
||||
var items: [Models.LibraryItem] { get }
|
||||
var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { get }
|
||||
|
||||
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import UserNotifications
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor func libraryItemMenu(dataService: DataService, viewModel: HomeFeedViewModel, item: LinkedItem) -> some View {
|
||||
@MainActor func libraryItemMenu(dataService: DataService, viewModel: HomeFeedViewModel, item: Models.LibraryItem) -> some View {
|
||||
Group {
|
||||
if item.state != "DELETED" {
|
||||
Button(
|
||||
|
|
@ -17,33 +17,33 @@ import Views
|
|||
action: { viewModel.itemUnderLabelEdit = item },
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
objectID: item.objectID,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
}
|
||||
}, label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
})
|
||||
Button("Remove Item", role: .destructive) {
|
||||
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
}
|
||||
if let author = item.author {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.searchTerm = "author:\"\(author)\""
|
||||
},
|
||||
label: {
|
||||
Label(String("More by \(author)"), systemImage: "person")
|
||||
}
|
||||
)
|
||||
}
|
||||
// Button(action: {
|
||||
// withAnimation(.linear(duration: 0.4)) {
|
||||
// viewModel.setLinkArchived(
|
||||
// dataService: dataService,
|
||||
// objectID: item.objectID,
|
||||
// archived: !item.isArchived
|
||||
// )
|
||||
// }
|
||||
// }, label: {
|
||||
// Label(
|
||||
// item.isArchived ? "Unarchive" : "Archive",
|
||||
// systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
// )
|
||||
// })
|
||||
// Button("Remove Item", role: .destructive) {
|
||||
// viewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
// }
|
||||
// if let author = item.author {
|
||||
// Button(
|
||||
// action: {
|
||||
// viewModel.filterState.searchTerm = "author:\"\(author)\""
|
||||
// },
|
||||
// label: {
|
||||
// Label(String("More by \(author)"), systemImage: "person")
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
} else {
|
||||
Button(
|
||||
action: { viewModel.recoverItem(dataService: dataService, itemID: item.unwrappedID) },
|
||||
|
|
|
|||
|
|
@ -10,16 +10,8 @@ import Models
|
|||
import SwiftUI
|
||||
|
||||
struct LibraryListView: View {
|
||||
@StateObject private var subViewModel = HomeFeedViewModel(
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@StateObject private var libraryViewModel = HomeFeedViewModel(
|
||||
fetcher: InboxFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
|
|
@ -28,15 +20,6 @@ struct LibraryListView: View {
|
|||
)
|
||||
)
|
||||
|
||||
@StateObject private var highlightsViewModel = HomeFeedViewModel(
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .highlights
|
||||
)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
// ZStack {
|
||||
// NavigationLink(
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@
|
|||
performTypeahead(searchTerm)
|
||||
}
|
||||
|
||||
func performSearch(_ searchTerm: String) {
|
||||
let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines)
|
||||
viewModel.saveRecentSearch(dataService: dataService, searchTerm: term)
|
||||
recents = viewModel.recentSearches(dataService: dataService)
|
||||
homeFeedViewModel.searchTerm = term
|
||||
func performSearch(_: String) {
|
||||
// let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines)
|
||||
// viewModel.saveRecentSearch(dataService: dataService, searchTerm: term)
|
||||
// recents = viewModel.recentSearches(dataService: dataService)
|
||||
// homeFeedViewModel.searchTerm = term
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import Views
|
|||
@MainActor
|
||||
struct ApplyLabelsView: View {
|
||||
enum Mode {
|
||||
case item(LinkedItem)
|
||||
case item(Models.LibraryItem)
|
||||
case highlight(Highlight)
|
||||
case list([LinkedItemLabel])
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import SwiftUI
|
|||
|
||||
func loadLabels(
|
||||
dataService: DataService,
|
||||
item: LinkedItem? = nil,
|
||||
item: Models.LibraryItem? = nil,
|
||||
highlight: Highlight? = nil,
|
||||
initiallySelectedLabels: [LinkedItemLabel]? = nil
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,14 @@ import Views
|
|||
struct LibraryTabView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@StateObject private var subViewModel = HomeFeedViewModel(
|
||||
@MainActor
|
||||
public init() {
|
||||
UITabBar.appearance().isHidden = true
|
||||
UITabBar.appearance().backgroundColor = UIColor(Color.themeTabBarColor)
|
||||
}
|
||||
|
||||
@StateObject private var followingViewModel = HomeFeedViewModel(
|
||||
fetcher: InboxFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
|
|
@ -26,6 +33,7 @@ struct LibraryTabView: View {
|
|||
)
|
||||
|
||||
@StateObject private var libraryViewModel = HomeFeedViewModel(
|
||||
fetcher: InboxFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
|
|
@ -35,6 +43,7 @@ struct LibraryTabView: View {
|
|||
)
|
||||
|
||||
@StateObject private var highlightsViewModel = HomeFeedViewModel(
|
||||
fetcher: InboxFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
|
|
@ -43,12 +52,31 @@ struct LibraryTabView: View {
|
|||
)
|
||||
)
|
||||
|
||||
@State var selectedTab = "following"
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
HomeView(viewModel: libraryViewModel)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
VStack(spacing: 0) {
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationView {
|
||||
HomeView(viewModel: followingViewModel)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
.tag("following")
|
||||
|
||||
NavigationView {
|
||||
HomeView(viewModel: libraryViewModel)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
.tag("inbox")
|
||||
|
||||
NavigationView {
|
||||
ProfileView()
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
.tag("profile")
|
||||
}
|
||||
CustomTabBar(selectedTab: $selectedTab)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import Views
|
|||
|
||||
@MainActor final class LinkItemDetailViewModel: ObservableObject {
|
||||
@Published var pdfItem: PDFItem?
|
||||
@Published var item: LinkedItem?
|
||||
@Published var item: Models.LibraryItem?
|
||||
|
||||
func loadItem(linkedItemObjectID: NSManagedObjectID, dataService: DataService) async {
|
||||
let item = await dataService.viewContext.perform {
|
||||
dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem
|
||||
dataService.viewContext.object(with: linkedItemObjectID) as? Models.LibraryItem
|
||||
}
|
||||
|
||||
if let item = item {
|
||||
|
|
@ -85,17 +85,11 @@ struct LinkItemDetailView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if isPDF {
|
||||
pdfContainerView
|
||||
} else if let item = viewModel.item {
|
||||
WebReaderContainerView(item: item, pop: { dismiss() })
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(true)
|
||||
.lazyPop(pop: {
|
||||
dismiss()
|
||||
}, isEnabled: $isEnabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import Views
|
|||
@Published var description = ""
|
||||
@Published var author = ""
|
||||
|
||||
func load(item: LinkedItem) {
|
||||
func load(item: Models.LibraryItem) {
|
||||
title = item.unwrappedTitle
|
||||
author = item.author ?? ""
|
||||
description = item.descriptionText ?? ""
|
||||
}
|
||||
|
||||
func submit(dataService: DataService, item: LinkedItem) {
|
||||
func submit(dataService: DataService, item: Models.LibraryItem) {
|
||||
dataService.updateLinkedItemTitleAndDescription(
|
||||
itemID: item.unwrappedID,
|
||||
title: title,
|
||||
|
|
@ -30,10 +30,10 @@ struct LinkedItemMetadataEditView: View {
|
|||
@Environment(\.presentationMode) private var presentationMode
|
||||
@StateObject var viewModel = LinkedItemMetadataEditViewModel()
|
||||
|
||||
let item: LinkedItem
|
||||
let item: Models.LibraryItem
|
||||
let onSave: ((String, String) -> Void)?
|
||||
|
||||
init(item: LinkedItem, onSave: ((String, String) -> Void)? = nil) {
|
||||
init(item: Models.LibraryItem, onSave: ((String, String) -> Void)? = nil) {
|
||||
self.item = item
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ import Views
|
|||
public var innerBody: some View {
|
||||
#if os(iOS)
|
||||
if UIDevice.isIPad {
|
||||
return AnyView(splitView)
|
||||
return AnyView(LibraryTabView())
|
||||
// return AnyView(splitView)
|
||||
} else {
|
||||
return AnyView(
|
||||
LibraryTabView()
|
||||
.navigationViewStyle(.stack)
|
||||
)
|
||||
}
|
||||
#else
|
||||
|
|
|
|||
|
|
@ -70,12 +70,7 @@ struct ProfileView: View {
|
|||
innerBody
|
||||
}
|
||||
.navigationTitle(LocalText.genericProfile)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import Views
|
|||
|
||||
func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObjectID) {
|
||||
dataService.viewContext.performAndWait {
|
||||
if let item = dataService.viewContext.object(with: objectID) as? LinkedItem {
|
||||
if let item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem {
|
||||
item.state = "DELETED"
|
||||
try? dataService.viewContext.save()
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObject
|
|||
print("canceling task", syncTask)
|
||||
syncTask.cancel()
|
||||
dataService.viewContext.performAndWait {
|
||||
if let item = dataService.viewContext.object(with: objectID) as? LinkedItem {
|
||||
if let item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem {
|
||||
item.state = "SUCCEEDED"
|
||||
try? dataService.viewContext.save()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct CustomTabBar: View {
|
||||
@Binding var selectedTab: String
|
||||
@Namespace var animation
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
TabBarButton(key: "following", image: Image.tabFollowing, selectedTab: $selectedTab, animation: animation)
|
||||
TabBarButton(key: "inbox", image: Image.tabLibrary, selectedTab: $selectedTab, animation: animation)
|
||||
TabBarButton(key: "profile", image: Image.tabHighlights, selectedTab: $selectedTab, animation: animation)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 40)
|
||||
.background(Color.themeTabBarColor)
|
||||
}
|
||||
}
|
||||
|
||||
struct TabBarButton: View {
|
||||
let key: String
|
||||
let image: Image
|
||||
@Binding var selectedTab: String
|
||||
var animation: Namespace.ID
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
withAnimation(.spring()) {
|
||||
selectedTab = key
|
||||
}
|
||||
}, label: {
|
||||
image
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(selectedTab == key ? Color.blue : Color.themeTabButtonColor)
|
||||
|
||||
.frame(maxWidth: .infinity)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import WebKit
|
|||
|
||||
@MainActor
|
||||
struct WebReader: PlatformViewRepresentable {
|
||||
let item: LinkedItem
|
||||
let item: Models.LibraryItem
|
||||
let viewModel: WebReaderViewModel
|
||||
let articleContent: ArticleContent
|
||||
let openLinkAction: (URL) -> Void
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import WebKit
|
|||
|
||||
// swiftlint:disable file_length type_body_length
|
||||
struct WebReaderContainerView: View {
|
||||
let item: LinkedItem
|
||||
let item: Models.LibraryItem
|
||||
let pop: () -> Void
|
||||
|
||||
@State private var showPreferencesPopover = false
|
||||
|
|
@ -209,7 +209,7 @@ struct WebReaderContainerView: View {
|
|||
)
|
||||
}
|
||||
|
||||
func menuItems(for item: LinkedItem) -> some View {
|
||||
func menuItems(for item: Models.LibraryItem) -> some View {
|
||||
let hasLabels = item.labels?.count != 0
|
||||
return Group {
|
||||
Button(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ struct WebReaderContent {
|
|||
let textFontSize: Int
|
||||
let lineHeight: Int
|
||||
let maxWidthPercentage: Int
|
||||
let item: LinkedItem
|
||||
let item: LibraryItem
|
||||
let isDark: Bool
|
||||
let themeKey: String
|
||||
let fontFamily: WebFont
|
||||
|
|
@ -17,7 +17,7 @@ struct WebReaderContent {
|
|||
let justifyText: Bool
|
||||
|
||||
init(
|
||||
item: LinkedItem,
|
||||
item: Models.LibraryItem,
|
||||
articleContent: ArticleContent,
|
||||
isDark: Bool,
|
||||
fontSize: Int,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import Utils
|
|||
import Views
|
||||
|
||||
@MainActor final class WebReaderLoadingContainerViewModel: ObservableObject {
|
||||
@Published var item: LinkedItem?
|
||||
@Published var item: Models.LibraryItem?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
func loadItem(dataService: DataService, username: String, requestID: String) async {
|
||||
|
|
@ -15,7 +15,7 @@ import Views
|
|||
else {
|
||||
return
|
||||
}
|
||||
item = dataService.viewContext.object(with: objectID) as? LinkedItem
|
||||
item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem
|
||||
}
|
||||
|
||||
func trackReadEvent() {
|
||||
|
|
@ -38,7 +38,6 @@ public struct WebReaderLoadingContainer: View {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@State var lazyPopIsEnabled = true
|
||||
@StateObject var viewModel = WebReaderLoadingContainerViewModel()
|
||||
|
||||
public var body: some View {
|
||||
|
|
@ -59,8 +58,6 @@ public struct WebReaderLoadingContainer: View {
|
|||
WebReaderContainerView(item: item, pop: { dismiss() })
|
||||
#if os(iOS)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarHidden(true)
|
||||
.lazyPop(pop: { dismiss() }, isEnabled: $lazyPopIsEnabled)
|
||||
#endif
|
||||
.accentColor(.appGrayTextContrast)
|
||||
.task { viewModel.trackReadEvent() }
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ struct SafariWebLink: Identifiable {
|
|||
showSnackbar = true
|
||||
}
|
||||
|
||||
func hasOriginalUrl(_ item: LinkedItem) -> Bool {
|
||||
func hasOriginalUrl(_ item: Models.LibraryItem) -> Bool {
|
||||
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
|
||||
if host == "omnivore.app" {
|
||||
return false
|
||||
|
|
@ -34,7 +34,7 @@ struct SafariWebLink: Identifiable {
|
|||
return false
|
||||
}
|
||||
|
||||
func downloadAudio(audioController: AudioController, item: LinkedItem) {
|
||||
func downloadAudio(audioController: AudioController, item: Models.LibraryItem) {
|
||||
snackbar(message: "Downloading Offline Audio")
|
||||
isDownloadingAudio = true
|
||||
|
||||
|
|
|
|||
|
|
@ -28,20 +28,21 @@
|
|||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="createdBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
|
||||
<relationship name="labels" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="highlights" inverseEntity="LinkedItemLabel"/>
|
||||
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="highlights" inverseEntity="LinkedItem"/>
|
||||
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LibraryItem" inverseName="highlights" inverseEntity="LibraryItem"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="LinkedItem" representedClassName="LinkedItem" syncable="YES" codeGenerationType="class">
|
||||
<entity name="LibraryItem" representedClassName="LibraryItem" 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="createdId" optional="YES" attributeType="String"/>
|
||||
<attribute name="descriptionText" optional="YES" attributeType="String"/>
|
||||
<attribute name="downloadURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="folder" optional="YES" attributeType="String"/>
|
||||
<attribute name="htmlContent" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="imageURLString" optional="YES" attributeType="String"/>
|
||||
|
|
@ -86,7 +87,7 @@
|
|||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<relationship name="highlights" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Highlight" inverseName="labels" inverseEntity="Highlight"/>
|
||||
<relationship name="linkedItems" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="labels" inverseEntity="LinkedItem"/>
|
||||
<relationship name="linkedItems" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LibraryItem" inverseName="labels" inverseEntity="LibraryItem"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
|
|
@ -113,7 +114,7 @@
|
|||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="note" optional="YES" attributeType="String"/>
|
||||
<attribute name="recommendedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="linkeditem" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="recommendations" inverseEntity="LinkedItem"/>
|
||||
<relationship name="linkeditem" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LibraryItem" inverseName="recommendations" inverseEntity="LibraryItem"/>
|
||||
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
|
||||
</entity>
|
||||
<entity name="RecommendationGroup" representedClassName="RecommendationGroup" syncable="YES" codeGenerationType="class">
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public struct JSONArticle: Decodable {
|
|||
public let updatedAt: Date
|
||||
public let savedAt: Date
|
||||
public let readAt: Date?
|
||||
public let folder: String
|
||||
public let image: String
|
||||
public let readingProgressPercent: Double
|
||||
public let readingProgressAnchorIndex: Int
|
||||
|
|
@ -59,7 +60,7 @@ public struct JSONArticle: Decodable {
|
|||
public let downloadURL: String
|
||||
}
|
||||
|
||||
public extension LinkedItem {
|
||||
public extension LibraryItem {
|
||||
var unwrappedID: String { id ?? "" }
|
||||
var unwrappedSlug: String { slug ?? "" }
|
||||
var unwrappedTitle: String { title ?? "" }
|
||||
|
|
@ -247,13 +248,13 @@ public extension LinkedItem {
|
|||
)
|
||||
}
|
||||
|
||||
static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LinkedItem? {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
static func lookup(byID itemID: String, inContext context: NSManagedObjectContext) -> LibraryItem? {
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "id == %@", itemID
|
||||
)
|
||||
|
||||
var item: LinkedItem?
|
||||
var item: LibraryItem?
|
||||
|
||||
context.performAndWait {
|
||||
item = (try? context.fetch(fetchRequest))?.first
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ public struct PDFItem {
|
|||
public let downloadURL: String
|
||||
public let highlights: [Highlight]
|
||||
|
||||
public static func make(item: LinkedItem) -> PDFItem? {
|
||||
public static func make(item: LibraryItem) -> PDFItem? {
|
||||
guard item.isPDF else { return nil }
|
||||
|
||||
return PDFItem(
|
||||
|
|
|
|||
|
|
@ -94,92 +94,92 @@ import Foundation
|
|||
// }
|
||||
// }
|
||||
|
||||
public enum FeaturedItemFilter: String, CaseIterable {
|
||||
case continueReading
|
||||
case recommended
|
||||
case newsletters
|
||||
case pinned
|
||||
}
|
||||
|
||||
public extension FeaturedItemFilter {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return "Continue Reading"
|
||||
case .recommended:
|
||||
return "Recommended"
|
||||
case .newsletters:
|
||||
return "Newsletters"
|
||||
case .pinned:
|
||||
return "Pinned"
|
||||
}
|
||||
}
|
||||
|
||||
var emptyMessage: String {
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return "Your recently read items will appear here."
|
||||
case .pinned:
|
||||
return "Create a label named Pinned and add it to items you would like to appear here."
|
||||
case .recommended:
|
||||
return "Reads recommended in your Clubs will appear here."
|
||||
case .newsletters:
|
||||
return "All your Newsletters will appear here."
|
||||
}
|
||||
}
|
||||
|
||||
var predicate: NSPredicate {
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
)
|
||||
let notInArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
|
||||
)
|
||||
|
||||
switch self {
|
||||
case .continueReading:
|
||||
// Use > 1 instead of 0 so its only reads they have made slight progress on.
|
||||
let continueReadingPredicate = NSPredicate(
|
||||
format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
continueReadingPredicate, undeletedPredicate, notInArchivePredicate
|
||||
])
|
||||
case .pinned:
|
||||
let pinnedPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, pinnedPredicate
|
||||
])
|
||||
case .newsletters:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
|
||||
])
|
||||
case .recommended:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let recommendedPredicate = NSPredicate(
|
||||
format: "recommendations.@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, recommendedPredicate
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
var sortDescriptor: NSSortDescriptor {
|
||||
let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false)
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false)
|
||||
case .pinned:
|
||||
return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false)
|
||||
default:
|
||||
return savedAtSort
|
||||
}
|
||||
}
|
||||
}
|
||||
// public enum FeaturedItemFilter: String, CaseIterable {
|
||||
// case continueReading
|
||||
// case recommended
|
||||
// case newsletters
|
||||
// case pinned
|
||||
// }
|
||||
//
|
||||
// public extension FeaturedItemFilter {
|
||||
// var title: String {
|
||||
// switch self {
|
||||
// case .continueReading:
|
||||
// return "Continue Reading"
|
||||
// case .recommended:
|
||||
// return "Recommended"
|
||||
// case .newsletters:
|
||||
// return "Newsletters"
|
||||
// case .pinned:
|
||||
// return "Pinned"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// var emptyMessage: String {
|
||||
// switch self {
|
||||
// case .continueReading:
|
||||
// return "Your recently read items will appear here."
|
||||
// case .pinned:
|
||||
// return "Create a label named Pinned and add it to items you would like to appear here."
|
||||
// case .recommended:
|
||||
// return "Reads recommended in your Clubs will appear here."
|
||||
// case .newsletters:
|
||||
// return "All your Newsletters will appear here."
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// var predicate: NSPredicate {
|
||||
// let undeletedPredicate = NSPredicate(
|
||||
// format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
// )
|
||||
// let notInArchivePredicate = NSPredicate(
|
||||
// format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
|
||||
// )
|
||||
//
|
||||
// switch self {
|
||||
// case .continueReading:
|
||||
// // Use > 1 instead of 0 so its only reads they have made slight progress on.
|
||||
// let continueReadingPredicate = NSPredicate(
|
||||
// format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil"
|
||||
// )
|
||||
// return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
// continueReadingPredicate, undeletedPredicate, notInArchivePredicate
|
||||
// ])
|
||||
// case .pinned:
|
||||
// let pinnedPredicate = NSPredicate(
|
||||
// format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
|
||||
// )
|
||||
// return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
// notInArchivePredicate, undeletedPredicate, pinnedPredicate
|
||||
// ])
|
||||
// case .newsletters:
|
||||
// // non-archived or deleted items with the Newsletter label
|
||||
// let newsletterLabelPredicate = NSPredicate(
|
||||
// format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
// )
|
||||
// return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
// notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
|
||||
// ])
|
||||
// case .recommended:
|
||||
// // non-archived or deleted items with the Newsletter label
|
||||
// let recommendedPredicate = NSPredicate(
|
||||
// format: "recommendations.@count > 0"
|
||||
// )
|
||||
// return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
// notInArchivePredicate, undeletedPredicate, recommendedPredicate
|
||||
// ])
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// var sortDescriptor: NSSortDescriptor {
|
||||
// let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false)
|
||||
// switch self {
|
||||
// case .continueReading:
|
||||
// return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false)
|
||||
// case .pinned:
|
||||
// return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false)
|
||||
// default:
|
||||
// return savedAtSort
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
|
|
|||
227
apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import Foundation
|
||||
|
||||
public enum LinkedItemFilter: String, CaseIterable {
|
||||
case inbox
|
||||
case feeds
|
||||
case readlater
|
||||
case newsletters
|
||||
case downloaded
|
||||
case recommended
|
||||
case all
|
||||
case archived
|
||||
case deleted
|
||||
case hasHighlights
|
||||
case files
|
||||
}
|
||||
|
||||
public extension LinkedItemFilter {
|
||||
var queryString: String {
|
||||
switch self {
|
||||
case .inbox:
|
||||
return "in:inbox"
|
||||
case .feeds:
|
||||
return "label:RSS"
|
||||
case .readlater:
|
||||
return "in:library"
|
||||
case .downloaded:
|
||||
return ""
|
||||
case .newsletters:
|
||||
return "in:inbox label:Newsletter"
|
||||
case .recommended:
|
||||
return "recommendedBy:*"
|
||||
case .all:
|
||||
return "in:all"
|
||||
case .archived:
|
||||
return "in:archive"
|
||||
case .deleted:
|
||||
return "in:trash"
|
||||
case .hasHighlights:
|
||||
return "has:highlights"
|
||||
case .files:
|
||||
return "type:file"
|
||||
}
|
||||
}
|
||||
|
||||
var allowLocalFetch: Bool {
|
||||
switch self {
|
||||
case .inbox:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var predicate: NSPredicate {
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i AND %K != \"DELETED\"",
|
||||
#keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
|
||||
#keyPath(LibraryItem.state)
|
||||
)
|
||||
let notInArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: false) as NSNumber
|
||||
)
|
||||
|
||||
switch self {
|
||||
case .inbox:
|
||||
// non-archived items
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate])
|
||||
case .readlater:
|
||||
// non-archived or deleted items without the Newsletter label
|
||||
let nonNewsletterLabelPredicate = NSPredicate(
|
||||
format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0"
|
||||
)
|
||||
let nonRSSPredicate = NSPredicate(
|
||||
format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
|
||||
])
|
||||
case .downloaded:
|
||||
// include pdf only
|
||||
let hasHTMLContent = NSPredicate(
|
||||
format: "htmlContent.length > 0"
|
||||
)
|
||||
let isPDFPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LibraryItem.contentReader), "PDF"
|
||||
)
|
||||
let localPDFURL = NSPredicate(
|
||||
format: "localPDF.length > 0"
|
||||
)
|
||||
let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL])
|
||||
return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF])
|
||||
case .newsletters:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate])
|
||||
case .feeds:
|
||||
let feedLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate])
|
||||
case .recommended:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let recommendedPredicate = NSPredicate(
|
||||
format: "recommendations.@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendedPredicate])
|
||||
case .all:
|
||||
// include everything undeleted
|
||||
return undeletedPredicate
|
||||
case .archived:
|
||||
let inArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: true) as NSNumber
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate])
|
||||
case .deleted:
|
||||
let deletedPredicate = NSPredicate(
|
||||
format: "%K == %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate])
|
||||
case .files:
|
||||
// include pdf only
|
||||
let isPDFPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LibraryItem.contentReader), "PDF"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate])
|
||||
case .hasHighlights:
|
||||
let hasHighlightsPredicate = NSPredicate(
|
||||
format: "highlights.@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
hasHighlightsPredicate
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum FeaturedItemFilter: String, CaseIterable {
|
||||
case continueReading
|
||||
case recommended
|
||||
case newsletters
|
||||
case pinned
|
||||
}
|
||||
|
||||
public extension FeaturedItemFilter {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return "Continue Reading"
|
||||
case .recommended:
|
||||
return "Recommended"
|
||||
case .newsletters:
|
||||
return "Newsletters"
|
||||
case .pinned:
|
||||
return "Pinned"
|
||||
}
|
||||
}
|
||||
|
||||
var emptyMessage: String {
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return "Your recently read items will appear here."
|
||||
case .pinned:
|
||||
return "Create a label named Pinned and add it to items you would like to appear here."
|
||||
case .recommended:
|
||||
return "Reads recommended in your Clubs will appear here."
|
||||
case .newsletters:
|
||||
return "All your Newsletters will appear here."
|
||||
}
|
||||
}
|
||||
|
||||
var predicate: NSPredicate {
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
)
|
||||
let notInArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: false) as NSNumber
|
||||
)
|
||||
|
||||
switch self {
|
||||
case .continueReading:
|
||||
// Use > 1 instead of 0 so its only reads they have made slight progress on.
|
||||
let continueReadingPredicate = NSPredicate(
|
||||
format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
continueReadingPredicate, undeletedPredicate, notInArchivePredicate
|
||||
])
|
||||
case .pinned:
|
||||
let pinnedPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, pinnedPredicate
|
||||
])
|
||||
case .newsletters:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
|
||||
])
|
||||
case .recommended:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let recommendedPredicate = NSPredicate(
|
||||
format: "recommendations.@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, recommendedPredicate
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
var sortDescriptor: NSSortDescriptor {
|
||||
let savedAtSort = NSSortDescriptor(key: #keyPath(LibraryItem.savedAt), ascending: false)
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return NSSortDescriptor(key: #keyPath(LibraryItem.readAt), ascending: false)
|
||||
case .pinned:
|
||||
return NSSortDescriptor(key: #keyPath(LibraryItem.updatedAt), ascending: false)
|
||||
default:
|
||||
return savedAtSort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,16 +24,16 @@ public extension LinkedItemSort {
|
|||
var sortDescriptors: [NSSortDescriptor] {
|
||||
switch self {
|
||||
case .newest:
|
||||
return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)]
|
||||
return [NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: false)]
|
||||
case .oldest:
|
||||
return [NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: true)]
|
||||
return [NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: true)]
|
||||
case .recentlyRead:
|
||||
return [
|
||||
NSSortDescriptor(keyPath: \LinkedItem.readAt, ascending: false),
|
||||
NSSortDescriptor(keyPath: \LinkedItem.savedAt, ascending: false)
|
||||
NSSortDescriptor(keyPath: \LibraryItem.readAt, ascending: false),
|
||||
NSSortDescriptor(keyPath: \LibraryItem.savedAt, ascending: false)
|
||||
]
|
||||
case .recentlyPublished:
|
||||
return [NSSortDescriptor(keyPath: \LinkedItem.publishDate, ascending: false)]
|
||||
return [NSSortDescriptor(keyPath: \LibraryItem.publishDate, ascending: false)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ extension DataService {
|
|||
}
|
||||
|
||||
func cachedArticleContent(itemID: String) async -> ArticleContent? {
|
||||
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
let linkedItemFetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
linkedItemFetchRequest.predicate = NSPredicate(
|
||||
format: "id == %@", itemID
|
||||
)
|
||||
|
|
@ -105,11 +105,11 @@ extension DataService {
|
|||
|
||||
await backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", articleProps.item.id)
|
||||
|
||||
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
|
||||
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
|
||||
let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: self.backgroundContext)
|
||||
objectID = linkedItem.objectID
|
||||
|
||||
let highlightObjects = articleProps.highlights.map {
|
||||
|
|
@ -190,14 +190,14 @@ extension DataService {
|
|||
/// - Returns: The id of the CoreData object if found.
|
||||
func linkedItemID(from requestID: String) async -> String? {
|
||||
await backgroundContext.perform(schedule: .immediate) {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "createdId == %@ OR id == %@", requestID, requestID)
|
||||
return try? self.backgroundContext.fetch(fetchRequest).first?.unwrappedID
|
||||
}
|
||||
}
|
||||
|
||||
func syncUnsyncedArticleContent(itemID: String) async {
|
||||
let linkedItemFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
let linkedItemFetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
linkedItemFetchRequest.predicate = NSPredicate(
|
||||
format: "id == %@", itemID
|
||||
)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ public final class DataService: ObservableObject {
|
|||
}
|
||||
|
||||
public func cleanupDeletedItems(in context: NSManagedObjectContext) {
|
||||
let fetchRequest: NSFetchRequest<LinkedItem> = LinkedItem.fetchRequest()
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
|
||||
let calendar = Calendar.current
|
||||
let oneDayAgo = calendar.date(byAdding: .day, value: -1, to: Date())!
|
||||
|
|
@ -219,12 +219,12 @@ public final class DataService: ObservableObject {
|
|||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "pageURLString = %@", normalizedURL)
|
||||
|
||||
let currentTime = Date()
|
||||
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
|
||||
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
|
||||
let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: self.backgroundContext)
|
||||
|
||||
linkedItem.createdId = requestId
|
||||
linkedItem.id = existingItem?.unwrappedID ?? requestId
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ extension DataService {
|
|||
}
|
||||
|
||||
func itemsNotInStore(from itemIDs: [String]) async -> [String] {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id IN %@", itemIDs)
|
||||
|
||||
return await backgroundContext.perform(schedule: .immediate) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ extension DataService {
|
|||
// 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 }
|
||||
guard let linkedItem = self.backgroundContext.object(with: objectID) as? LibraryItem else { return }
|
||||
linkedItem.update(inContext: self.backgroundContext, newIsArchivedValue: archived)
|
||||
|
||||
// Send update to server
|
||||
|
|
@ -54,7 +54,7 @@ extension DataService {
|
|||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public extension DataService {
|
|||
// If the item is still available locally, update its state
|
||||
backgroundContext.performAndWait {
|
||||
items.forEach { itemID in
|
||||
if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) {
|
||||
if let linkedItem = Models.LibraryItem.lookup(byID: itemID, inContext: backgroundContext) {
|
||||
if action == .delete {
|
||||
linkedItem.state = "DELETED"
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ public extension DataService {
|
|||
|
||||
var linkedItemID: String?
|
||||
viewContext.performAndWait {
|
||||
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
|
||||
guard let linkedItem = self.viewContext.object(with: objectID) as? LibraryItem else { return }
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
linkedItemID = linkedItem.id
|
||||
}
|
||||
|
||||
viewContext.perform {
|
||||
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
|
||||
guard let linkedItem = self.viewContext.object(with: objectID) as? LibraryItem else { return }
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
linkedItemID = linkedItem.id
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ public extension DataService {
|
|||
let isSyncSuccess = data != nil
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
|
||||
if isSyncSuccess {
|
||||
linkedItem.remove(inContext: context)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ public extension DataService {
|
|||
var itemUpdatedLocal = false
|
||||
// If the item is still available locally, update its state
|
||||
backgroundContext.performAndWait {
|
||||
if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) {
|
||||
if let linkedItem = LibraryItem.lookup(byID: itemID, inContext: backgroundContext) {
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsUpdate.rawValue)
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ public extension DataService {
|
|||
func setItemLabels(itemID: String, labels: [InternalLinkedItemLabel]) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
if let existingLabels = linkedItem.labels {
|
||||
linkedItem.removeFromLabels(existingLabels)
|
||||
|
|
@ -65,7 +65,7 @@ public extension DataService {
|
|||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ public extension DataService {
|
|||
func updateLinkListeningProgress(itemID: String, listenIndex: Int, listenOffset: Double, listenTime: Double) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
linkedItem.update(
|
||||
inContext: self.backgroundContext,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ extension DataService {
|
|||
public func updateLinkReadingProgress(itemID: String, readingProgress: Double, anchorIndex: Int, force: Bool?) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
if let force = force, !force {
|
||||
if readingProgress != 0, readingProgress < linkedItem.readingProgress {
|
||||
|
|
@ -70,7 +70,7 @@ extension DataService {
|
|||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
if let mutationResult = data?.data, case let MutationResult.saved(readAt) = mutationResult {
|
||||
linkedItem.readAt = readAt
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ extension DataService {
|
|||
public func updateLinkedItemTitleAndDescription(itemID: String, title: String, description: String, author: String?) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
linkedItem.update(
|
||||
inContext: self.backgroundContext,
|
||||
|
|
@ -65,7 +65,7 @@ extension DataService {
|
|||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import Utils
|
|||
|
||||
public extension DataService {
|
||||
func syncOfflineItemsWithServerIfNeeded() async throws {
|
||||
var unsyncedLinkedItems = [LinkedItem]()
|
||||
var unsyncedLinkedItems = [LibraryItem]()
|
||||
var unsyncedHighlights = [Highlight]()
|
||||
|
||||
// LinkedItems
|
||||
let itemsFetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
let itemsFetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
itemsFetchRequest.predicate = NSPredicate(
|
||||
format: "serverSyncStatus != %i", Int64(ServerSyncStatus.isNSync.rawValue)
|
||||
)
|
||||
|
|
@ -37,7 +37,7 @@ public extension DataService {
|
|||
|
||||
private func updateLinkedItemStatus(id: String, newId: String?, status: ServerSyncStatus) async throws {
|
||||
backgroundContext.performAndWait {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
|
||||
|
||||
guard let linkedItem = (try? backgroundContext.fetch(fetchRequest))?.first else { return }
|
||||
|
|
@ -107,7 +107,7 @@ public extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
func syncLocalCreatedLinkedItem(item: LinkedItem) {
|
||||
func syncLocalCreatedLinkedItem(item: LibraryItem) {
|
||||
switch item.contentReader {
|
||||
case "PDF":
|
||||
let id = item.unwrappedID
|
||||
|
|
@ -135,7 +135,7 @@ public extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
private func syncLinkedItems(unsyncedLinkedItems: [LinkedItem]) {
|
||||
private func syncLinkedItems(unsyncedLinkedItems: [LibraryItem]) {
|
||||
for item in unsyncedLinkedItems {
|
||||
guard let syncStatus = ServerSyncStatus(rawValue: Int(item.serverSyncStatus)) else { continue }
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public extension DataService {
|
|||
) async throws -> LinkedItemSyncResult {
|
||||
let fetchResult = try await linkedItemUpdates(since: since, limit: 20, cursor: cursor, descending: descending)
|
||||
|
||||
LinkedItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
|
||||
LibraryItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
|
||||
|
||||
if fetchResult.items.persist(context: backgroundContext) == nil {
|
||||
throw BasicError.message(messageText: "CoreData error")
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ public extension DataService {
|
|||
.appendingPathComponent(UUID().uuidString + ".pdf")
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LinkedItem.slug), slug)
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.slug), slug)
|
||||
|
||||
let linkedItem = try? self?.backgroundContext.fetch(fetchRequest).first
|
||||
guard let linkedItem = linkedItem else {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import SwiftGraphQL
|
|||
import Utils
|
||||
|
||||
struct ArticleProps {
|
||||
let item: InternalLinkedItem
|
||||
let item: InternalLibraryItem
|
||||
let htmlContent: String
|
||||
let highlights: [InternalHighlight]
|
||||
}
|
||||
|
|
@ -20,13 +20,14 @@ extension DataService {
|
|||
|
||||
let articleContentSelection = Selection.Article {
|
||||
ArticleProps(
|
||||
item: InternalLinkedItem(
|
||||
item: InternalLibraryItem(
|
||||
id: try $0.id(),
|
||||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
readAt: try $0.readAt()?.value,
|
||||
updatedAt: try $0.updatedAt()?.value ?? Date(),
|
||||
folder: try $0.folder(),
|
||||
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import Models
|
|||
import SwiftGraphQL
|
||||
|
||||
struct InternalLinkedItemQueryResult {
|
||||
let items: [InternalLinkedItem]
|
||||
let items: [InternalLibraryItem]
|
||||
let cursor: String?
|
||||
}
|
||||
|
||||
struct InternalLinkedItemUpdatesQueryResult {
|
||||
let items: [InternalLinkedItem]
|
||||
let items: [InternalLibraryItem]
|
||||
let deletedItemIDs: [String]
|
||||
let cursor: String?
|
||||
let hasMoreItems: Bool
|
||||
|
|
@ -19,7 +19,7 @@ struct InternalLinkedItemUpdatesQueryResult {
|
|||
private struct SyncItemEdge {
|
||||
let itemID: String
|
||||
let isDeletedItem: Bool
|
||||
let item: InternalLinkedItem?
|
||||
let item: InternalLibraryItem?
|
||||
}
|
||||
|
||||
extension DataService {
|
||||
|
|
@ -90,7 +90,7 @@ extension DataService {
|
|||
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
var items = [InternalLinkedItem]()
|
||||
var items = [InternalLibraryItem]()
|
||||
var deletedItemIDs = [String]()
|
||||
|
||||
for edge in result.edges {
|
||||
|
|
@ -186,13 +186,13 @@ extension DataService {
|
|||
/// - itemID: id of the item being requested
|
||||
/// - Returns: Returns an `InternalLinkedItem` or throws a `ContentFetchError` if
|
||||
/// request could not be completed
|
||||
func fetchLinkedItem(username: String, itemID: String) async throws -> InternalLinkedItem {
|
||||
func fetchLinkedItem(username: String, itemID: String) async throws -> InternalLibraryItem {
|
||||
struct ArticleProps {
|
||||
let item: InternalLinkedItem
|
||||
let item: InternalLibraryItem
|
||||
}
|
||||
|
||||
enum QueryResult {
|
||||
case success(result: InternalLinkedItem)
|
||||
case success(result: InternalLibraryItem)
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
|
|
@ -252,13 +252,14 @@ let recommendationSelection = Selection.Recommendation {
|
|||
}
|
||||
|
||||
private let libraryArticleSelection = Selection.Article {
|
||||
InternalLinkedItem(
|
||||
InternalLibraryItem(
|
||||
id: try $0.id(),
|
||||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
readAt: try $0.readAt()?.value,
|
||||
updatedAt: try $0.updatedAt()?.value ?? Date(),
|
||||
folder: try $0.folder(),
|
||||
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
|
|
@ -292,13 +293,14 @@ private let syncItemEdgeSelection = Selection.SyncUpdatedItemEdge {
|
|||
}
|
||||
|
||||
private let searchItemSelection = Selection.SearchItem {
|
||||
InternalLinkedItem(
|
||||
InternalLibraryItem(
|
||||
id: try $0.id(),
|
||||
title: try $0.title(),
|
||||
createdAt: try $0.createdAt().value ?? Date(),
|
||||
savedAt: try $0.savedAt().value ?? Date(),
|
||||
readAt: try $0.readAt()?.value,
|
||||
updatedAt: try $0.updatedAt()?.value ?? Date(),
|
||||
folder: try $0.folder(),
|
||||
state: try $0.state()?.rawValue.asArticleContentStatus ?? .succeeded,
|
||||
readingProgress: try $0.readingProgressPercent(),
|
||||
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
|
||||
|
|
|
|||
|
|
@ -120,11 +120,11 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
|
|||
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i AND %K != \"DELETED\"",
|
||||
#keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
|
||||
#keyPath(LinkedItem.state)
|
||||
#keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
|
||||
#keyPath(Models.LibraryItem.state)
|
||||
)
|
||||
let notInArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
|
||||
format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: false) as NSNumber
|
||||
)
|
||||
|
||||
switch name {
|
||||
|
|
@ -148,7 +148,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
|
|||
format: "htmlContent.length > 0"
|
||||
)
|
||||
let isPDFPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF"
|
||||
format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF"
|
||||
)
|
||||
let localPDFURL = NSPredicate(
|
||||
format: "localPDF.length > 0"
|
||||
|
|
@ -177,18 +177,18 @@ public struct InternalFilter: Encodable, Identifiable, Hashable {
|
|||
return undeletedPredicate
|
||||
case "Archived":
|
||||
let inArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber
|
||||
format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: true) as NSNumber
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate])
|
||||
case "Deleted":
|
||||
let deletedPredicate = NSPredicate(
|
||||
format: "%K == %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
format: "%K == %i", #keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate])
|
||||
case "Files":
|
||||
// include pdf only
|
||||
let isPDFPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF"
|
||||
format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate])
|
||||
case "Highlights":
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ struct InternalHighlight: Encodable {
|
|||
let highlight = asManagedObject(context: context)
|
||||
|
||||
if let associatedItemID = associatedItemID {
|
||||
let linkedItem = LinkedItem.lookup(byID: associatedItemID, inContext: context)
|
||||
let linkedItem = LibraryItem.lookup(byID: associatedItemID, inContext: context)
|
||||
linkedItem?.addToHighlights(highlight)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import CoreData
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
struct InternalLinkedItem {
|
||||
struct InternalLibraryItem {
|
||||
let id: String
|
||||
let title: String
|
||||
let createdAt: Date
|
||||
let savedAt: Date
|
||||
let readAt: Date?
|
||||
let updatedAt: Date
|
||||
let folder: String
|
||||
let state: ArticleContentStatus
|
||||
var readingProgress: Double
|
||||
var readingProgressAnchor: Int
|
||||
|
|
@ -38,9 +39,9 @@ struct InternalLinkedItem {
|
|||
return pageURLString.hasSuffix("pdf")
|
||||
}
|
||||
|
||||
func asManagedObject(inContext context: NSManagedObjectContext) -> LinkedItem {
|
||||
let existingItem = LinkedItem.lookup(byID: id, inContext: context)
|
||||
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: context)
|
||||
func asManagedObject(inContext context: NSManagedObjectContext) -> LibraryItem {
|
||||
let existingItem = LibraryItem.lookup(byID: id, inContext: context)
|
||||
let linkedItem = existingItem ?? LibraryItem(entity: LibraryItem.entity(), insertInto: context)
|
||||
|
||||
linkedItem.id = id
|
||||
linkedItem.title = title
|
||||
|
|
@ -48,6 +49,7 @@ struct InternalLinkedItem {
|
|||
linkedItem.savedAt = savedAt
|
||||
linkedItem.updatedAt = updatedAt
|
||||
linkedItem.readAt = readAt
|
||||
linkedItem.folder = folder
|
||||
linkedItem.state = state.rawValue
|
||||
linkedItem.readingProgress = readingProgress
|
||||
linkedItem.readingProgressAnchor = Int64(readingProgressAnchor)
|
||||
|
|
@ -89,9 +91,9 @@ struct InternalLinkedItem {
|
|||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == InternalLinkedItem {
|
||||
extension Sequence where Element == InternalLibraryItem {
|
||||
func persist(context: NSManagedObjectContext) -> [NSManagedObjectID]? {
|
||||
var linkedItems: [LinkedItem]?
|
||||
var linkedItems: [LibraryItem]?
|
||||
context.performAndWait {
|
||||
linkedItems = map { $0.asManagedObject(inContext: context) }
|
||||
|
||||
|
|
@ -123,13 +125,14 @@ extension JSONArticle {
|
|||
func persistAsLinkedItem(context: NSManagedObjectContext) -> NSManagedObjectID? {
|
||||
var objectID: NSManagedObjectID?
|
||||
|
||||
let internalLinkedItem = InternalLinkedItem(
|
||||
let internalLinkedItem = InternalLibraryItem(
|
||||
id: id,
|
||||
title: title,
|
||||
createdAt: createdAt,
|
||||
savedAt: savedAt,
|
||||
readAt: readAt,
|
||||
updatedAt: updatedAt,
|
||||
folder: folder,
|
||||
state: .succeeded,
|
||||
readingProgress: readingProgressPercent,
|
||||
readingProgressAnchor: readingProgressAnchorIndex,
|
||||
|
|
@ -54,6 +54,8 @@ public extension Color {
|
|||
|
||||
static var noteContainer: Color { Color("_noteContainer", bundle: .module) }
|
||||
static var textFieldBackground: Color { Color("_textFieldBackground", bundle: .module) }
|
||||
static var themeTabBarColor: Color { Color("_themeTabBarColor", bundle: .module) }
|
||||
static var themeTabButtonColor: Color { Color("_themeTabButtonColor", bundle: .module) }
|
||||
|
||||
// Apple system UIColor equivalents
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2A",
|
||||
"green" : "0x2A",
|
||||
"red" : "0x2A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x93",
|
||||
"green" : "0x8E",
|
||||
"red" : "0x8E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x93",
|
||||
"green" : "0x8E",
|
||||
"red" : "0x8E"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -12,12 +12,12 @@ public enum GridCardAction {
|
|||
|
||||
public struct GridCard: View {
|
||||
@Binding var isContextMenuOpen: Bool
|
||||
let item: LinkedItem
|
||||
let item: Models.LibraryItem
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
// let tapAction: () -> Void
|
||||
|
||||
public init(
|
||||
item: LinkedItem,
|
||||
item: Models.LibraryItem,
|
||||
isContextMenuOpen: Binding<Bool>,
|
||||
actionHandler: @escaping (GridCardAction) -> Void
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import Utils
|
|||
public struct LibraryFeatureCard: View {
|
||||
let viewer: Viewer?
|
||||
let tapHandler: () -> Void
|
||||
@ObservedObject var item: LinkedItem
|
||||
@ObservedObject var item: Models.LibraryItem
|
||||
|
||||
public init(item: LinkedItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) {
|
||||
public init(item: Models.LibraryItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) {
|
||||
self.item = item
|
||||
self.viewer = viewer
|
||||
self.tapHandler = tapHandler
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ enum FlairLabels: String {
|
|||
}
|
||||
|
||||
public extension View {
|
||||
func draggableItem(item: LinkedItem) -> some View {
|
||||
func draggableItem(item: Models.LibraryItem) -> some View {
|
||||
#if os(iOS)
|
||||
if #available(iOS 16.0, *), let url = item.deepLink {
|
||||
return AnyView(self.draggable(url) {
|
||||
|
|
@ -46,10 +46,10 @@ public extension View {
|
|||
|
||||
public struct LibraryItemCard: View {
|
||||
let viewer: Viewer?
|
||||
@ObservedObject var item: LinkedItem
|
||||
@ObservedObject var item: Models.LibraryItem
|
||||
@State var noteLineLimit: Int? = 3
|
||||
|
||||
public init(item: LinkedItem, viewer: Viewer?) {
|
||||
public init(item: Models.LibraryItem, viewer: Viewer?) {
|
||||
self.item = item
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,11 @@ public extension Image {
|
|||
static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) }
|
||||
static var googleIcon: Image { Image("_googleIcon", bundle: .module) }
|
||||
|
||||
static var homeTab: Image { Image("BookmarksSimple", bundle: .module) }
|
||||
static var homeTabSelected: Image { Image("_homeTabSelected", bundle: .module) }
|
||||
static var profileTab: Image { Image("_profileTab", bundle: .module) }
|
||||
static var profileTabSelected: Image { Image("_profileTabSelected", bundle: .module) }
|
||||
static var dotsThree: Image { Image("_dots-three", bundle: .module) }
|
||||
|
||||
static var tabSubscriptions: Image { Image("_tab_subscriptions", bundle: .module).renderingMode(.template) }
|
||||
static var tabFollowing: Image { Image("_tab_following", bundle: .module).renderingMode(.template) }
|
||||
static var tabLibrary: Image { Image("_tab_library", bundle: .module).renderingMode(.template) }
|
||||
static var tabBriefing: Image { Image("_tab_briefing", bundle: .module).renderingMode(.template) }
|
||||
static var tabSearch: Image { Image("_tab_search", bundle: .module).renderingMode(.template) }
|
||||
static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) }
|
||||
|
||||
static var pinRotated: Image { Image("pin-rotated", bundle: .module) }
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "profile-tab.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#9D9B95" stroke-width="2" stroke-miterlimit="10"/>
|
||||
<path d="M12 15C14.0711 15 15.75 13.3211 15.75 11.25C15.75 9.17893 14.0711 7.5 12 7.5C9.92893 7.5 8.25 9.17893 8.25 11.25C8.25 13.3211 9.92893 15 12 15Z" stroke="#9D9B95" stroke-width="2" stroke-miterlimit="10"/>
|
||||
<path d="M5.98114 18.6913C6.54609 17.5806 7.40738 16.6478 8.46967 15.9963C9.53196 15.3448 10.7538 15 12 15C13.2461 15 14.468 15.3448 15.5303 15.9963C16.5926 16.6478 17.4539 17.5806 18.0188 18.6913" stroke="#9D9B95" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 780 B |
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "profile-tab-selected.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="15" cy="15" r="15" fill="#F4DA7B"/>
|
||||
<path d="M15 24C19.9706 24 24 19.9706 24 15C24 10.0294 19.9706 6 15 6C10.0294 6 6 10.0294 6 15C6 19.9706 10.0294 24 15 24Z" stroke="#382D05" stroke-width="2" stroke-miterlimit="10"/>
|
||||
<path d="M15 18C17.0711 18 18.75 16.3211 18.75 14.25C18.75 12.1789 17.0711 10.5 15 10.5C12.9289 10.5 11.25 12.1789 11.25 14.25C11.25 16.3211 12.9289 18 15 18Z" stroke="#382D05" stroke-width="2" stroke-miterlimit="10"/>
|
||||
<path d="M8.98114 21.6913C9.54609 20.5806 10.4074 19.6478 11.4697 18.9963C12.532 18.3448 13.7538 18 15 18C16.2461 18 17.468 18.3448 18.5303 18.9963C19.5926 19.6478 20.4539 20.5806 21.0188 21.6913" stroke="#382D05" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -5,7 +5,7 @@
|
|||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "BookmarksSimple.png",
|
||||
"filename" : "Frame.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
12
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_following.imageset/Frame.svg
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5638_26950)">
|
||||
<path d="M12.5 4.56641L4.5 8.56641L12.5 12.5664L20.5 8.56641L12.5 4.56641Z" fill="#8E8E93" stroke="#8E8E93" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 12.5664L12.5 16.5664L20.5 12.5664" stroke="#8E8E93" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 16.5664L12.5 20.5664L20.5 16.5664" stroke="#8E8E93" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5638_26950">
|
||||
<rect width="24" height="24" fill="white" transform="translate(0.5 0.566406)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 674 B |
|
Before Width: | Height: | Size: 482 B |
|
|
@ -5,7 +5,7 @@
|
|||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "BookOpen.png",
|
||||
"filename" : "Frame.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
|
|
|
|||
11
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_library.imageset/Frame.svg
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5638_26956)">
|
||||
<path d="M13.5 6.56641H7.5C6.70435 6.56641 5.94129 6.88248 5.37868 7.44509C4.81607 8.0077 4.5 8.77076 4.5 9.56641V21.5664L4.506 21.6804C4.52514 21.8466 4.58565 22.0053 4.68199 22.142C4.77833 22.2787 4.90743 22.3891 5.05746 22.463C5.20749 22.5369 5.37366 22.572 5.54077 22.5651C5.70788 22.5582 5.87059 22.5095 6.014 22.4234L10.5 19.7324L14.986 22.4234C15.1377 22.5143 15.3108 22.5634 15.4876 22.5655C15.6645 22.5677 15.8387 22.523 15.9926 22.4359C16.1465 22.3488 16.2746 22.2224 16.3637 22.0696C16.4528 21.9169 16.4999 21.7433 16.5 21.5664V9.56641C16.5 8.77076 16.1839 8.0077 15.6213 7.44509C15.0587 6.88248 14.2956 6.56641 13.5 6.56641Z" fill="#8E8E93"/>
|
||||
<path d="M17.5 2.56641C18.2652 2.56636 19.0015 2.85873 19.5582 3.38369C20.115 3.90865 20.4501 4.62651 20.495 5.39041L20.5 5.56641V17.5664C20.5 17.7337 20.4581 17.8983 20.3781 18.0452C20.298 18.192 20.1824 18.3165 20.0419 18.4071C19.9013 18.4978 19.7402 18.5517 19.5734 18.564C19.4066 18.5763 19.2393 18.5465 19.087 18.4774L18.986 18.4234L17.986 17.8234C17.8004 17.712 17.656 17.5433 17.5744 17.3428C17.4929 17.1423 17.4786 16.9208 17.5337 16.7114C17.5888 16.5021 17.7104 16.3163 17.8801 16.182C18.0499 16.0476 18.2586 15.972 18.475 15.9664L18.5 15.9674V5.56641C18.5 5.32147 18.41 5.08507 18.2473 4.90203C18.0845 4.719 17.8602 4.60206 17.617 4.57341L17.5 4.56641H11.499C11.3432 4.56606 11.1894 4.60213 11.05 4.67175C10.9106 4.74138 10.7893 4.84262 10.696 4.96741L10.566 5.16441C10.4124 5.36587 10.1881 5.50152 9.9383 5.54397C9.68854 5.58641 9.43199 5.53248 9.22046 5.39307C9.00893 5.25366 8.8582 5.03916 8.79871 4.79291C8.73923 4.54666 8.77542 4.287 8.89999 4.06641C9.14644 3.63932 9.49437 3.27957 9.91298 3.01899C10.3316 2.7584 10.808 2.60502 11.3 2.57241L11.5 2.56641H17.5Z" fill="#8E8E93"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5638_26956">
|
||||
<rect width="24" height="24" fill="white" transform="translate(0.5 0.566406)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
|
|
@ -5,7 +5,7 @@
|
|||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Group 1000002644.png",
|
||||
"filename" : "MagnifyingGlass.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_search.imageset/MagnifyingGlass.png
vendored
Normal file
|
After Width: | Height: | Size: 552 B |
|
Before Width: | Height: | Size: 707 B |
|
|
@ -19,7 +19,7 @@ public enum Theme: String, CaseIterable {
|
|||
public var bgColor: Color {
|
||||
switch self {
|
||||
case .system:
|
||||
return Color.isDarkMode ? .systemBackground : .white
|
||||
return Color.isDarkMode ? .black : .white
|
||||
case .light:
|
||||
return .white
|
||||
case .dark:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
scalars:
|
||||
Date: DateTime
|
||||
JSON: String
|
||||
SanitizedString: String
|
||||
SanitizedString_undefined_15: String
|
||||
SanitizedString_undefined_40: String
|
||||
|
|
|
|||