Allow setting labels on highlights
This commit is contained in:
@ -11,6 +11,7 @@ struct HighlightsListCard: View {
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
let onSaveAnnotation: (String) -> Void
|
||||
let onDeleteHighlight: () -> Void
|
||||
let onSetLabels: (String) -> Void
|
||||
|
||||
var contextMenuView: some View {
|
||||
Group {
|
||||
@ -30,6 +31,12 @@ struct HighlightsListCard: View {
|
||||
},
|
||||
label: { Label("Copy", systemImage: "doc.on.doc") }
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
onSetLabels(highlightParams.highlightID)
|
||||
},
|
||||
label: { Label("Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: onDeleteHighlight,
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
@ -109,6 +116,20 @@ struct HighlightsListCard: View {
|
||||
} else {
|
||||
noteSection
|
||||
}
|
||||
|
||||
if highlightParams.labels.count > 0 {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(highlightParams.labels, id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}.introspectScrollView { scrollView in
|
||||
scrollView.bounces = false
|
||||
}
|
||||
.padding(.top, 0)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ struct HighlightsListView: View {
|
||||
|
||||
let itemObjectID: NSManagedObjectID
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
@State var setLabelsHighlight: Highlight?
|
||||
|
||||
var emptyView: some View {
|
||||
Text("""
|
||||
@ -61,10 +62,21 @@ struct HighlightsListView: View {
|
||||
highlightID: highlightParams.highlightID,
|
||||
dataService: dataService
|
||||
)
|
||||
},
|
||||
onSetLabels: { highlightID in
|
||||
setLabelsHighlight = Highlight.lookup(byID: highlightID, inContext: dataService.viewContext)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}.sheet(item: $setLabelsHighlight) { highlight in
|
||||
ApplyLabelsView(mode: .highlight(highlight), onSave: { selectedLabels in
|
||||
hasHighlightMutations = true
|
||||
|
||||
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
|
||||
labels: selectedLabels,
|
||||
dataService: dataService)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ struct HighlightListItemParams: Identifiable {
|
||||
let title: String
|
||||
let annotation: String
|
||||
let quote: String
|
||||
let labels: [LinkedItemLabel]
|
||||
}
|
||||
|
||||
@MainActor final class HighlightsListViewModel: ObservableObject {
|
||||
@ -29,7 +30,8 @@ struct HighlightListItemParams: Identifiable {
|
||||
highlightID: highlightID,
|
||||
title: highlightItems[index].title,
|
||||
annotation: annotation,
|
||||
quote: highlightItems[index].quote
|
||||
quote: highlightItems[index].quote,
|
||||
labels: highlightItems[index].labels
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -39,6 +41,20 @@ struct HighlightListItemParams: Identifiable {
|
||||
highlightItems.removeAll { $0.highlightID == highlightID }
|
||||
}
|
||||
|
||||
func setLabelsForHighlight(highlightID: String, labels: [LinkedItemLabel], dataService: DataService) {
|
||||
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: labels.map(\.unwrappedID))
|
||||
|
||||
if let index = highlightItems.firstIndex(where: { $0.highlightID == highlightID }) {
|
||||
highlightItems[index] = HighlightListItemParams(
|
||||
highlightID: highlightID,
|
||||
title: highlightItems[index].title,
|
||||
annotation: highlightItems[index].annotation,
|
||||
quote: highlightItems[index].quote,
|
||||
labels: labels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadHighlights(item: LinkedItem) {
|
||||
let unsortedHighlights = item.highlights.asArray(of: Highlight.self)
|
||||
|
||||
@ -51,7 +67,8 @@ struct HighlightListItemParams: Identifiable {
|
||||
highlightID: $0.unwrappedID,
|
||||
title: "Highlight",
|
||||
annotation: $0.annotation ?? "",
|
||||
quote: $0.quote ?? ""
|
||||
quote: $0.quote ?? "",
|
||||
labels: $0.labels.asArray(of: LinkedItemLabel.self)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,12 @@ import Views
|
||||
struct ApplyLabelsView: View {
|
||||
enum Mode {
|
||||
case item(LinkedItem)
|
||||
case highlight(Highlight)
|
||||
case list([LinkedItemLabel])
|
||||
|
||||
var navTitle: String {
|
||||
switch self {
|
||||
case .item:
|
||||
case .item, .highlight:
|
||||
return "Assign Labels"
|
||||
case .list:
|
||||
return "Apply Label Filters"
|
||||
@ -19,7 +20,7 @@ struct ApplyLabelsView: View {
|
||||
|
||||
var confirmButtonText: String {
|
||||
switch self {
|
||||
case .item:
|
||||
case .item, .highlight:
|
||||
return "Save"
|
||||
case .list:
|
||||
return "Apply"
|
||||
@ -109,6 +110,8 @@ struct ApplyLabelsView: View {
|
||||
switch mode {
|
||||
case let .item(feedItem):
|
||||
viewModel.saveItemLabelChanges(itemID: feedItem.unwrappedID, dataService: dataService)
|
||||
case .highlight:
|
||||
onSave?(viewModel.selectedLabels)
|
||||
case .list:
|
||||
onSave?(viewModel.selectedLabels)
|
||||
}
|
||||
@ -148,6 +151,8 @@ struct ApplyLabelsView: View {
|
||||
switch mode {
|
||||
case let .item(feedItem):
|
||||
await viewModel.loadLabels(dataService: dataService, item: feedItem)
|
||||
case let .highlight(highlight):
|
||||
await viewModel.loadLabels(dataService: dataService, highlight: highlight)
|
||||
case let .list(labels):
|
||||
await viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
|
||||
}
|
||||
|
||||
@ -36,6 +36,29 @@ import Views
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadLabels(
|
||||
dataService: DataService,
|
||||
highlight: Highlight
|
||||
) async {
|
||||
isLoading = true
|
||||
|
||||
if let labelIDs = try? await dataService.labels() {
|
||||
dataService.viewContext.performAndWait {
|
||||
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
|
||||
}
|
||||
let selLabels = highlight.labels ?? []
|
||||
for label in labels {
|
||||
if selLabels.contains(label) {
|
||||
selectedLabels.append(label)
|
||||
} else {
|
||||
unselectedLabels.append(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadLabelsFromStore(dataService: DataService) async {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
|
||||
|
||||
@ -95,6 +118,10 @@ import Views
|
||||
dataService.updateItemLabels(itemID: itemID, labelIDs: selectedLabels.map(\.unwrappedID))
|
||||
}
|
||||
|
||||
func saveHighlightLabelChanges(highlightID: String, dataService: DataService) {
|
||||
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: selectedLabels.map(\.unwrappedID))
|
||||
}
|
||||
|
||||
func addLabelToItem(_ label: LinkedItemLabel) {
|
||||
selectedLabels.insert(label, at: 0)
|
||||
unselectedLabels.removeAll { $0.name == label.name }
|
||||
|
||||
@ -11,6 +11,7 @@ struct WebReaderContainerView: View {
|
||||
|
||||
@State private var showPreferencesPopover = false
|
||||
@State private var showLabelsModal = false
|
||||
@State private var showHighlightLabelsModal = false
|
||||
@State private var showTitleEdit = false
|
||||
@State private var showHighlightsView = false
|
||||
@State private var hasPerformedHighlightMutations = false
|
||||
@ -271,6 +272,9 @@ struct WebReaderContainerView: View {
|
||||
.sheet(isPresented: $showLabelsModal) {
|
||||
ApplyLabelsView(mode: .item(item), onSave: { _ in showLabelsModal = false })
|
||||
}
|
||||
.sheet(isPresented: $showHighlightLabelsModal) {
|
||||
ApplyLabelsView(mode: .item(item), onSave: { _ in showLabelsModal = false })
|
||||
}
|
||||
.sheet(isPresented: $showTitleEdit) {
|
||||
LinkedItemMetadataEditView(item: item)
|
||||
}
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func setLabelsForHighlight(highlightID: String, labelIDs: [String]) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let highlight = Highlight.lookup(byID: highlightID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
if let existingLabels = highlight.labels {
|
||||
highlight.removeFromLabels(existingLabels)
|
||||
}
|
||||
|
||||
for labelID in labelIDs {
|
||||
if let labelObject = LinkedItemLabel.lookup(byID: labelID, inContext: self.backgroundContext) {
|
||||
highlight.addToLabels(labelObject)
|
||||
}
|
||||
}
|
||||
|
||||
// Send update to server
|
||||
self.syncHighlightLabelUpdates(highlightID: highlightID, labelIDs: labelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func syncHighlightLabelUpdates(highlightID: String, labelIDs: [String]) {
|
||||
enum MutationResult {
|
||||
case saved(labels: [InternalLinkedItemLabel])
|
||||
case error(errorCode: Enums.SetLabelsErrorCode)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SetLabelsResult> {
|
||||
try $0.on(
|
||||
setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
setLabelsSuccess: .init { .saved(labels: try $0.labels(selection: highlightLabelSelection.list)) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setLabelsForHighlight(
|
||||
input: InputObjects.SetLabelsForHighlightInput(
|
||||
highlightId: highlightID,
|
||||
labelIds: labelIDs
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
let context = backgroundContext
|
||||
|
||||
send(mutation, to: path, headers: headers) { result in
|
||||
let data = try? result.get()
|
||||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let highlight = Highlight.lookup(byID: highlightID, inContext: context) else { return }
|
||||
highlight.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Highlight labels updated succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to update highlight labels: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
struct InternalLinkedItemLabel: Encodable {
|
||||
public struct InternalLinkedItemLabel: Encodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let color: String
|
||||
@ -39,7 +39,7 @@ struct InternalLinkedItemLabel: Encodable {
|
||||
return label
|
||||
}
|
||||
|
||||
static func make(_ labels: NSSet?) -> [InternalLinkedItemLabel] {
|
||||
public static func make(_ labels: NSSet?) -> [InternalLinkedItemLabel] {
|
||||
labels?
|
||||
.compactMap { label in
|
||||
if let label = label as? LinkedItemLabel {
|
||||
|
||||
@ -208,10 +208,12 @@ public final class OmnivoreWebView: WKWebView {
|
||||
// on iOS16 we use menuBuilder to create these items
|
||||
currentMenu = .defaultMenu
|
||||
let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection))
|
||||
let labels = UIMenuItem(title: "Labels", action: #selector(setLabels))
|
||||
|
||||
let remove = UIMenuItem(title: "Remove", action: #selector(removeSelection))
|
||||
// let share = UIMenuItem(title: "Share", action: #selector(shareSelection))
|
||||
|
||||
UIMenuController.shared.menuItems = [remove, /* share, */ annotate]
|
||||
UIMenuController.shared.menuItems = [remove, labels, annotate]
|
||||
}
|
||||
}
|
||||
|
||||
@ -235,6 +237,7 @@ public final class OmnivoreWebView: WKWebView {
|
||||
case #selector(shareSelection): return true
|
||||
case #selector(removeSelection): return true
|
||||
case #selector(copy(_:)): return true
|
||||
case #selector(setLabels(_:)): return true
|
||||
case Selector(("_lookup:")): return true
|
||||
case Selector(("_define:")): return true
|
||||
case Selector(("_findSelected:")): return true
|
||||
@ -292,15 +295,18 @@ public final class OmnivoreWebView: WKWebView {
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
@objc public func setLabels(_: Any?) {}
|
||||
|
||||
override public func buildMenu(with builder: UIMenuBuilder) {
|
||||
if #available(iOS 16.0, *) {
|
||||
let annotate = UICommand(title: "Note", action: #selector(annotateSelection))
|
||||
let highlight = UICommand(title: "Highlight", action: #selector(highlightSelection))
|
||||
let remove = UICommand(title: "Remove", action: #selector(removeSelection))
|
||||
let setLabels = UICommand(title: "Labels", action: #selector(setLabels))
|
||||
|
||||
let omnivore = UIMenu(title: "",
|
||||
options: .displayInline,
|
||||
children: currentMenu == .defaultMenu ? [highlight, annotate] : [annotate, remove])
|
||||
children: currentMenu == .defaultMenu ? [highlight, annotate] : [annotate, setLabels, remove])
|
||||
builder.insertSibling(omnivore, beforeMenu: .lookup)
|
||||
}
|
||||
|
||||
|
||||
@ -78,8 +78,20 @@ public struct TextChip: View {
|
||||
.font(.appCaptionBold)
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(1)
|
||||
.background(Capsule().fill(backgroundColor))
|
||||
.overlay(Capsule().stroke(borderColor, lineWidth: 1))
|
||||
// .background(Capsule().fill(backgroundColor))
|
||||
// .overlay(Capsule().stroke(borderColor, lineWidth: 1))
|
||||
// .background(Rectangle()
|
||||
// .stroke(borderColor, lineWidth: 1)
|
||||
// .background(Rectangle().fill(backgroundColor))
|
||||
// .cornerRadius(4)
|
||||
// )
|
||||
// .overlay(Rectangle().stroke(borderColor, lineWidth: 1).cornerRadius(4))
|
||||
.background(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(borderColor, lineWidth: 1)
|
||||
)
|
||||
|
||||
.padding(1)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if checked {
|
||||
|
||||
Reference in New Issue
Block a user