diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index baba3253f..c470508b6 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -69,7 +69,6 @@ jobs: yarn build yarn lint yarn test - env: PG_HOST: localhost PG_PORT: ${{ job.services.postgres.ports[5432] }} @@ -78,3 +77,12 @@ jobs: PG_DB: omnivore_test PG_POOL_MAX: 10 ELASTIC_URL: http://localhost:${{ job.services.elastic.ports[9200] }}/ + build-docker-images: + name: Build docker images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Build the API docker image + run: 'docker build --file packages/api/Dockerfile .' diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 63919c49c..dbf0084ca 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -7,9 +7,6 @@ struct FeedCardNavigationLink: View { @EnvironmentObject var dataService: DataService let item: FeedItem - let searchQuery: String - - @Binding var selectedLinkItem: FeedItem? @ObservedObject var viewModel: HomeFeedViewModel @@ -18,14 +15,14 @@ struct FeedCardNavigationLink: View { NavigationLink( destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)), tag: item, - selection: $selectedLinkItem + selection: $viewModel.selectedLinkItem ) { EmptyView() } .opacity(0) .buttonStyle(PlainButtonStyle()) .onAppear { - viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) + viewModel.itemAppeared(item: item, dataService: dataService) } FeedCard(item: item) } @@ -36,13 +33,10 @@ struct GridCardNavigationLink: View { @EnvironmentObject var dataService: DataService @State private var scale = 1.0 - @State private var isActive = false let item: FeedItem - let searchQuery: String let actionHandler: (GridCardAction) -> Void - @Binding var selectedLinkItem: FeedItem? @Binding var isContextMenuOpen: Bool @ObservedObject var viewModel: HomeFeedViewModel @@ -51,7 +45,8 @@ struct GridCardNavigationLink: View { ZStack { NavigationLink( destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)), - isActive: $isActive + tag: item, + selection: $viewModel.selectedLinkItem ) { EmptyView() } @@ -60,15 +55,15 @@ struct GridCardNavigationLink: View { scale = 0.95 DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) { scale = 1.0 - isActive = true + viewModel.selectedLinkItem = item } } }) .onAppear { - viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) + viewModel.itemAppeared(item: item, dataService: dataService) } } - .aspectRatio(2.1, contentMode: .fill) + .aspectRatio(1.8, contentMode: .fill) .scaleEffect(scale) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 4c29dd93b..23a610b1e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -10,10 +10,6 @@ import Views struct HomeFeedContainerView: View { @EnvironmentObject var dataService: DataService @AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = UIDevice.isIPhone - @State private var searchQuery = "" - @State private var snoozePresented = false - @State private var itemToSnooze: FeedItem? - @State private var selectedLinkItem: FeedItem? @ObservedObject var viewModel: HomeFeedViewModel var body: some View { @@ -21,50 +17,55 @@ import Views if #available(iOS 15.0, *) { HomeFeedView( prefersListLayout: $prefersListLayout, - searchQuery: $searchQuery, - selectedLinkItem: $selectedLinkItem, - snoozePresented: $snoozePresented, - itemToSnooze: $itemToSnooze, viewModel: viewModel ) .refreshable { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) } .searchable( - text: $searchQuery, - placement: .sidebar + text: $viewModel.searchTerm, + placement: .navigationBarDrawer ) { - if searchQuery.isEmpty { + if viewModel.searchTerm.isEmpty { Text("Inbox").searchCompletion("in:inbox ") Text("All").searchCompletion("in:all ") Text("Archived").searchCompletion("in:archive ") Text("Files").searchCompletion("type:file ") } } - .onChange(of: searchQuery) { _ in + .onChange(of: viewModel.searchTerm) { _ in // Maybe we should debounce this, but // it feels like it works ok without - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) + } + .onChange(of: viewModel.selectedLabels) { _ in + viewModel.loadItems(dataService: dataService, isRefresh: true) } .onSubmit(of: .search) { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) + } + .sheet(item: $viewModel.itemUnderLabelEdit) { item in + ApplyLabelsView(mode: .item(item)) { labels in + viewModel.updateLabels(itemID: item.id, labels: labels) + } } } else { HomeFeedView( prefersListLayout: $prefersListLayout, - searchQuery: $searchQuery, - selectedLinkItem: $selectedLinkItem, - snoozePresented: $snoozePresented, - itemToSnooze: $itemToSnooze, viewModel: viewModel ) + .sheet(item: $viewModel.itemUnderLabelEdit) { item in + ApplyLabelsView(mode: .item(item)) { labels in + viewModel.updateLabels(itemID: item.id, labels: labels) + } + } .toolbar { ToolbarItem { if viewModel.isLoading { Button(action: {}, label: { ProgressView() }) } else { Button( - action: { viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) }, + action: { viewModel.loadItems(dataService: dataService, isRefresh: true) }, label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } ) } @@ -73,20 +74,21 @@ import Views } } .navigationTitle("Home") + .navigationBarTitleDisplayMode(.inline) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in // Don't refresh the list if the user is currently reading an article - if selectedLinkItem == nil { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + if viewModel.selectedLinkItem == nil { + viewModel.loadItems(dataService: dataService, isRefresh: true) } } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushFeedItem"))) { notification in if let feedItem = notification.userInfo?["feedItem"] as? FeedItem { viewModel.pushFeedItem(item: feedItem) - self.selectedLinkItem = feedItem + viewModel.selectedLinkItem = feedItem } } - .formSheet(isPresented: $snoozePresented) { - SnoozeView(snoozePresented: $snoozePresented, itemToSnooze: $itemToSnooze) { + .formSheet(isPresented: $viewModel.snoozePresented) { + SnoozeView(snoozePresented: $viewModel.snoozePresented, itemToSnooze: $viewModel.itemToSnooze) { viewModel.snoozeUntil( dataService: dataService, linkId: $0.feedItemId, @@ -97,73 +99,79 @@ import Views } .onAppear { if viewModel.items.isEmpty { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) } } + .onChange(of: viewModel.selectedLinkItem) { _ in + viewModel.commitItemUpdates() + } } } struct HomeFeedView: View { @EnvironmentObject var dataService: DataService - @Binding var prefersListLayout: Bool - @Binding var searchQuery: String - @Binding var selectedLinkItem: FeedItem? - @Binding var snoozePresented: Bool - @Binding var itemToSnooze: FeedItem? - + @State private var showLabelsSheet = false @ObservedObject var viewModel: HomeFeedViewModel var body: some View { - if prefersListLayout { - HomeFeedListView( - prefersListLayout: $prefersListLayout, - searchQuery: $searchQuery, - selectedLinkItem: $selectedLinkItem, - snoozePresented: $snoozePresented, - itemToSnooze: $itemToSnooze, - viewModel: viewModel - ) - } else { - HomeFeedGridView( - searchQuery: $searchQuery, - selectedLinkItem: $selectedLinkItem, - snoozePresented: $snoozePresented, - itemToSnooze: $itemToSnooze, - viewModel: viewModel - ) - .toolbar { - ToolbarItem { - if #available(iOS 15.0, *) { - Button("", action: {}) - .disabled(true) - .overlay { - if viewModel.isLoading { - ProgressView() - } - } - } else { - if viewModel.isLoading { - Button(action: {}, label: { ProgressView() }) - } else { - Button( - action: { viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) }, - label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } - ) + VStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + TextChipButton.makeAddLabelButton { + showLabelsSheet = true + } + ForEach(viewModel.selectedLabels, id: \.self) { label in + TextChipButton.makeRemovableLabelButton(feedItemLabel: label) { + viewModel.selectedLabels.removeAll { $0.id == label.id } } } + Spacer() } - ToolbarItem { - if UIDevice.isIPad { - Button( - action: { prefersListLayout.toggle() }, - label: { - Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") - } - ) + .padding(.horizontal) + .sheet(isPresented: $showLabelsSheet) { + ApplyLabelsView(mode: .list(viewModel.selectedLabels)) { labels in + viewModel.selectedLabels = labels } } } + if prefersListLayout { + HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel) + } else { + HomeFeedGridView(viewModel: viewModel) + .toolbar { + ToolbarItem { + if #available(iOS 15.0, *) { + Button("", action: {}) + .disabled(true) + .overlay { + if viewModel.isLoading { + ProgressView() + } + } + } else { + if viewModel.isLoading { + Button(action: {}, label: { ProgressView() }) + } else { + Button( + action: { viewModel.loadItems(dataService: dataService, isRefresh: true) }, + label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } + ) + } + } + } + ToolbarItem { + if UIDevice.isIPad { + Button( + action: { prefersListLayout.toggle() }, + label: { + Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") + } + ) + } + } + } + } } } } @@ -171,10 +179,6 @@ import Views struct HomeFeedListView: View { @EnvironmentObject var dataService: DataService @Binding var prefersListLayout: Bool - @Binding var searchQuery: String - @Binding var selectedLinkItem: FeedItem? - @Binding var snoozePresented: Bool - @Binding var itemToSnooze: FeedItem? @State private var itemToRemove: FeedItem? @State private var confirmationShown = false @@ -187,11 +191,13 @@ import Views ForEach(viewModel.items) { item in let link = FeedCardNavigationLink( item: item, - searchQuery: searchQuery, - selectedLinkItem: $selectedLinkItem, viewModel: viewModel ) .contextMenu { + Button( + action: { viewModel.itemUnderLabelEdit = item }, + label: { Label("Edit Labels", systemImage: "tag") } + ) Button(action: { withAnimation(.linear(duration: 0.4)) { viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived) @@ -211,8 +217,8 @@ import Views ) if FeatureFlag.enableSnooze { Button { - itemToSnooze = item - snoozePresented = true + viewModel.itemToSnooze = item + viewModel.snoozePresented = true } label: { Label { Text("Snooze") } icon: { Image.moon } } @@ -264,8 +270,8 @@ import Views .swipeActions(edge: .leading, allowsFullSwipe: true) { if FeatureFlag.enableSnooze { Button { - itemToSnooze = item - snoozePresented = true + viewModel.itemToSnooze = item + viewModel.snoozePresented = true } label: { Label { Text("Snooze") } icon: { Image.moon } }.tint(.appYellow48) @@ -302,10 +308,6 @@ import Views struct HomeFeedGridView: View { @EnvironmentObject var dataService: DataService - @Binding var searchQuery: String - @Binding var selectedLinkItem: FeedItem? - @Binding var snoozePresented: Bool - @Binding var itemToSnooze: FeedItem? @State private var itemToRemove: FeedItem? @State private var confirmationShown = false @@ -320,18 +322,18 @@ import Views case .delete: itemToRemove = item confirmationShown = true + case .editLabels: + viewModel.itemUnderLabelEdit = item } } var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 24)], spacing: 24) { - ForEach(viewModel.items, id: \.renderID) { item in + ForEach(viewModel.items) { item in let link = GridCardNavigationLink( item: item, - searchQuery: searchQuery, actionHandler: { contextMenuActionHandler(item: item, action: $0) }, - selectedLinkItem: $selectedLinkItem, isContextMenuOpen: $isContextMenuOpen, viewModel: viewModel ) @@ -365,7 +367,7 @@ import Views .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in DispatchQueue.main.async { if !viewModel.isLoading, offset > 240 { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index bd4849c14..640f6fdc5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -9,12 +9,8 @@ import Views #if os(macOS) struct HomeFeedView: View { @EnvironmentObject var dataService: DataService - @State var searchQuery = "" - @State private var selectedLinkItem: FeedItem? @State private var itemToRemove: FeedItem? @State private var confirmationShown = false - @State private var snoozePresented = false - @State private var itemToSnooze: FeedItem? @ObservedObject var viewModel: HomeFeedViewModel @@ -33,8 +29,6 @@ import Views ForEach(viewModel.items) { item in FeedCardNavigationLink( item: item, - searchQuery: searchQuery, - selectedLinkItem: $selectedLinkItem, viewModel: viewModel ) .contextMenu { @@ -55,8 +49,8 @@ import Views ) if FeatureFlag.enableSnooze { Button { - itemToSnooze = item - snoozePresented = true + viewModel.itemToSnooze = item + viewModel.snoozePresented = true } label: { Label { Text("Snooze") } icon: { Image.moon } } @@ -83,29 +77,29 @@ import Views .listStyle(PlainListStyle()) .navigationTitle("Home") .searchable( - text: $searchQuery, + text: $viewModel.searchTerm, placement: .toolbar ) { - if searchQuery.isEmpty { + if viewModel.searchTerm.isEmpty { Text("Inbox").searchCompletion("in:inbox ") Text("All").searchCompletion("in:all ") Text("Archived").searchCompletion("in:archive ") Text("Files").searchCompletion("type:file ") } } - .onChange(of: searchQuery) { _ in + .onChange(of: viewModel.searchTerm) { _ in // Maybe we should debounce this, but // it feels like it works ok without - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) } .onSubmit(of: .search) { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) } .toolbar { ToolbarItem { Button( action: { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) }, label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } ) @@ -120,7 +114,7 @@ import Views } .onAppear { if viewModel.items.isEmpty { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) } } } @@ -131,8 +125,6 @@ import Views ForEach(viewModel.items) { item in FeedCardNavigationLink( item: item, - searchQuery: searchQuery, - selectedLinkItem: $selectedLinkItem, viewModel: viewModel ) } @@ -148,7 +140,7 @@ import Views ToolbarItem { Button( action: { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) }, label: { Label("Refresh Feed", systemImage: "arrow.clockwise") } ) @@ -156,7 +148,7 @@ import Views } .onAppear { if viewModel.items.isEmpty { - viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) + viewModel.loadItems(dataService: dataService, isRefresh: true) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index e0ebabb8d..4ee67a777 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -8,9 +8,22 @@ import Views final class HomeFeedViewModel: ObservableObject { var currentDetailViewModel: LinkItemDetailViewModel? + /// Track progress updates to be committed when user navigates back to grid view + var uncommittedReadingProgressUpdates = [String: Double]() + + /// Track label updates to be committed when user navigates back to grid view + var uncommittedLabelUpdates = [String: [FeedItemLabel]]() + @Published var items = [FeedItem]() @Published var isLoading = false @Published var showPushNotificationPrimer = false + @Published var itemUnderLabelEdit: FeedItem? + @Published var searchTerm = "" + @Published var selectedLabels = [FeedItemLabel]() + @Published var snoozePresented = false + @Published var itemToSnooze: FeedItem? + @Published var selectedLinkItem: FeedItem? + var cursor: String? var sendProgressUpdates = false @@ -23,14 +36,14 @@ final class HomeFeedViewModel: ObservableObject { init() {} - func itemAppeared(item: FeedItem, searchQuery: String, dataService: DataService) { + func itemAppeared(item: FeedItem, dataService: DataService) { if isLoading { return } let itemIndex = items.firstIndex(where: { $0.id == item.id }) let thresholdIndex = items.index(items.endIndex, offsetBy: -5) // Check if user has scrolled to the last five items in the list if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 { - loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: false) + loadItems(dataService: dataService, isRefresh: false) } } @@ -38,7 +51,7 @@ final class HomeFeedViewModel: ObservableObject { items.insert(item, at: 0) } - func loadItems(dataService: DataService, searchQuery: String?, isRefresh: Bool) { + func loadItems(dataService: DataService, isRefresh: Bool) { // Clear offline highlights since we'll be populating new FeedItems with the correct highlights set dataService.clearHighlights() @@ -76,6 +89,9 @@ final class HomeFeedViewModel: ObservableObject { if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { return } + + dataService.prefetchPages(items: result.items) + self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items self?.isLoading = false self?.receivedIdx = thisSearchIdx @@ -160,10 +176,53 @@ final class HomeFeedViewModel: ObservableObject { .store(in: &subscriptions) } - func updateProgress(itemID: String, progress: Double) { + /// Update `FeedItem`s with the cached reading progress and label values so it can animate when the + /// user navigates back to the grid view (and also avoid mutations of the grid items + /// that can cause the `NavigationView` to pop. + func commitItemUpdates() { + for (key, value) in uncommittedReadingProgressUpdates { + updateProgress(itemID: key, progress: value) + } + for (key, value) in uncommittedLabelUpdates { + updateLabels(itemID: key, labels: value) + } + uncommittedReadingProgressUpdates = [:] + uncommittedLabelUpdates = [:] + } + + private func updateProgress(itemID: String, progress: Double) { guard sendProgressUpdates, let item = items.first(where: { $0.id == itemID }) else { return } if let index = items.firstIndex(of: item) { items[index].readingProgress = progress } } + + func updateLabels(itemID: String, labels: [FeedItemLabel]) { + // If item is being being displayed then delay the state update of labels until + // user is no longer reading the item. + if selectedLinkItem != nil { + uncommittedLabelUpdates[itemID] = labels + return + } + + guard let item = items.first(where: { $0.id == itemID }) else { return } + if let index = items.firstIndex(of: item) { + items[index].labels = labels + } + } + + private var searchQuery: String? { + if searchTerm.isEmpty, selectedLabels.isEmpty { + return nil + } + + var query = searchTerm + + if !selectedLabels.isEmpty { + query.append(" label:") + query.append(selectedLabels.map(\.name).joined(separator: ",")) + } + + return query + } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift new file mode 100644 index 000000000..8cf320384 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -0,0 +1,161 @@ +import Models +import Services +import SwiftUI +import Views + +struct ApplyLabelsView: View { + enum Mode { + case item(FeedItem) + case list([FeedItemLabel]) + + var navTitle: String { + switch self { + case .item: + return "Assign Labels" + case .list: + return "Apply Label Filters" + } + } + + var confirmButtonText: String { + switch self { + case .item: + return "Save" + case .list: + return "Apply" + } + } + } + + let mode: Mode + let commitLabelChanges: ([FeedItemLabel]) -> Void + + @EnvironmentObject var dataService: DataService + @Environment(\.presentationMode) private var presentationMode + @StateObject var viewModel = LabelsViewModel() + @State private var labelSearchFilter = "" + + var innerBody: some View { + List { + Section(header: Text("Assigned Labels")) { + if viewModel.selectedLabels.isEmpty { + Text("No labels are currently assigned.") + } + ForEach(viewModel.selectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in + HStack { + TextChip(feedItemLabel: label) + Spacer() + Button( + action: { + withAnimation { + viewModel.removeLabelFromItem(label) + } + }, + label: { Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast) } + ) + } + } + } + Section(header: Text("Available Labels")) { + ForEach(viewModel.unselectedLabels.applySearchFilter(labelSearchFilter), id: \.self) { label in + HStack { + TextChip(feedItemLabel: label) + Spacer() + Button( + action: { + withAnimation { + viewModel.addLabelToItem(label) + } + }, + label: { Image(systemName: "plus").foregroundColor(.appGrayTextContrast) } + ) + } + } + } + Section { + Button( + action: { viewModel.showCreateEmailModal = true }, + label: { + HStack { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + Text("Create a new Label").foregroundColor(.appGrayTextContrast) + Spacer() + } + } + ) + .disabled(viewModel.isLoading) + } + } + .navigationTitle(mode.navTitle) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button( + action: { presentationMode.wrappedValue.dismiss() }, + label: { Text("Cancel").foregroundColor(.appGrayTextContrast) } + ) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: { + switch mode { + case let .item(feedItem): + viewModel.saveItemLabelChanges(itemID: feedItem.id, dataService: dataService) { labels in + commitLabelChanges(labels) + presentationMode.wrappedValue.dismiss() + } + case .list: + commitLabelChanges(viewModel.selectedLabels) + presentationMode.wrappedValue.dismiss() + } + }, + label: { Text(mode.confirmButtonText).foregroundColor(.appGrayTextContrast) } + ) + } + } + #endif + .sheet(isPresented: $viewModel.showCreateEmailModal) { + CreateLabelView(viewModel: viewModel) + } + } + + var body: some View { + NavigationView { + if viewModel.isLoading { + EmptyView() + } else { + #if os(iOS) + if #available(iOS 15.0, *) { + innerBody + .searchable( + text: $labelSearchFilter, + placement: .navigationBarDrawer(displayMode: .always) + ) + } else { + innerBody + } + #else + innerBody + #endif + } + } + .onAppear { + switch mode { + case let .item(feedItem): + viewModel.loadLabels(dataService: dataService, item: feedItem) + case let .list(labels): + viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels) + } + } + } +} + +private extension Sequence where Element == FeedItemLabel { + func applySearchFilter(_ searchFilter: String) -> [FeedItemLabel] { + if searchFilter.isEmpty { + return map { $0 } // return the identity of the sequence + } + return filter { $0.name.lowercased().contains(searchFilter.lowercased()) } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift new file mode 100644 index 000000000..24633a1e5 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -0,0 +1,233 @@ +import Combine +import Models +import Services +import SwiftUI +import Utils +import Views + +struct LabelsView: View { + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = LabelsViewModel() + @State private var showDeleteConfirmation = false + @State private var labelToRemoveID: String? + + let footerText = "Use labels to create curated collections of links." + + var body: some View { + Group { + #if os(iOS) + if #available(iOS 15.0, *) { + Form { + innerBody + .alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) { + Button("Delete Label", role: .destructive) { + if let labelID = labelToRemoveID { + withAnimation { + viewModel.deleteLabel(dataService: dataService, labelID: labelID) + } + } + self.labelToRemoveID = nil + } + Button("Cancel", role: .cancel) { self.labelToRemoveID = nil } + } + } + } else { + Form { innerBody } + } + + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + .onAppear { viewModel.loadLabels(dataService: dataService, item: nil) } + } + + private var innerBody: some View { + Group { + Section(footer: Text(footerText)) { + Button( + action: { viewModel.showCreateEmailModal = true }, + label: { + HStack { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + Text("Create a new Label").foregroundColor(.appGrayTextContrast) + Spacer() + } + } + ) + .disabled(viewModel.isLoading) + } + + if !viewModel.labels.isEmpty { + Section(header: Text("Labels")) { + ForEach(viewModel.labels, id: \.id) { label in + HStack { + TextChip(feedItemLabel: label) + Spacer() + Button( + action: { + labelToRemoveID = label.id + showDeleteConfirmation = true + }, + label: { Image(systemName: "trash") } + ) + } + } + } + } + } + .navigationTitle("Labels") + .sheet(isPresented: $viewModel.showCreateEmailModal) { + CreateLabelView(viewModel: viewModel) + } + } +} + +struct CreateLabelView: View { + @EnvironmentObject var dataService: DataService + @ObservedObject var viewModel: LabelsViewModel + + @State private var newLabelName = "" + @State private var newLabelColor = Color.clear + + var shouldDisableCreateButton: Bool { + viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear + } + + let rows = [ + GridItem(.fixed(60)), + GridItem(.fixed(60)), + GridItem(.fixed(70)) + ] + + let swatches: [Color] = { + let webSwatches = webSwatchHexes.map { Color(hex: $0) ?? .clear } + var additionalSwatches = swatchHexes.map { Color(hex: $0) ?? .clear }.shuffled() + let firstSwatch = additionalSwatches.remove(at: 0) + return [firstSwatch] + webSwatches + additionalSwatches + }() + + var body: some View { + NavigationView { + VStack { + HStack { + if !newLabelName.isEmpty, newLabelColor != .clear { + TextChip(text: newLabelName, color: newLabelColor) + } else { + Text("Assign a name and color.").font(.appBody) + } + Spacer() + } + .padding(.bottom, 8) + + TextField("Label Name", text: $newLabelName) + #if os(iOS) + .keyboardType(.alphabet) + #endif + .textFieldStyle(StandardTextFieldStyle()) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid(rows: rows, alignment: .top, spacing: 20) { + ForEach(swatches, id: \.self) { swatch in + ZStack { + Circle() + .fill(swatch) + .frame(width: 50, height: 50) + .onTapGesture { + newLabelColor = swatch + } + .padding(10) + + if newLabelColor == swatch { + Circle() + .stroke(swatch, lineWidth: 5) + .frame(width: 60, height: 60) + } + } + } + } + } + Spacer() + } + .padding() + .toolbar { + ToolbarItem(placement: .barLeading) { + Button( + action: { viewModel.showCreateEmailModal = false }, + label: { Text("Cancel").foregroundColor(.appGrayTextContrast) } + ) + } + ToolbarItem(placement: .barTrailing) { + Button( + action: { + viewModel.createLabel( + dataService: dataService, + name: newLabelName, + color: newLabelColor, + description: nil + ) + }, + label: { Text("Create").foregroundColor(.appGrayTextContrast) } + ) + .opacity(shouldDisableCreateButton ? 0.2 : 1) + .disabled(shouldDisableCreateButton) + } + } + .navigationTitle("Create New Label") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .onAppear { + newLabelColor = swatches.first ?? .clear + } + } + } +} + +private let webSwatchHexes = [ + "#FF5D99", + "#7CFF7B", + "#FFD234", + "#7BE4FF", + "#CE88EF", + "#EF8C43" +] + +private let swatchHexes = [ + "#fff034", + "#efff34", + "#d1ff34", + "#b2ff34", + "#94ff34", + "#75ff34", + "#57ff34", + "#38ff34", + "#34ff4e", + "#34ff6d", + "#34ff8b", + "#34ffa9", + "#34ffc8", + "#34ffe6", + "#34f9ff", + "#34dbff", + "#34bcff", + "#349eff", + "#347fff", + "#3461ff", + "#3443ff", + "#4434ff", + "#6234ff", + "#8134ff", + "#9f34ff", + "#be34ff", + "#dc34ff", + "#fb34ff", + "#ff34e5", + "#ff34c7", + "#ff34a8", + "#ff348a", + "#ff346b" +] diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift new file mode 100644 index 000000000..35159e8ca --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -0,0 +1,105 @@ +import Combine +import Models +import Services +import SwiftUI +import Views + +final class LabelsViewModel: ObservableObject { + private var hasLoadedInitialLabels = false + @Published var isLoading = false + @Published var selectedLabels = [FeedItemLabel]() + @Published var unselectedLabels = [FeedItemLabel]() + @Published var labels = [FeedItemLabel]() + @Published var showCreateEmailModal = false + + var subscriptions = Set() + + /// Loads initial set of labels when a edit labels list is displayed + /// - Parameters: + /// - dataService: `DataService` reference + /// - item: Optional `FeedItem` for applying labels to a single item + /// - initiallySelectedLabels: Optional `[FeedItemLabel]` for filtering a list of items + func loadLabels(dataService: DataService, item: FeedItem? = nil, initiallySelectedLabels: [FeedItemLabel]? = nil) { + guard !hasLoadedInitialLabels else { return } + isLoading = true + + dataService.labelsPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] allLabels in + self?.isLoading = false + self?.labels = allLabels + self?.hasLoadedInitialLabels = true + if let item = item { + self?.selectedLabels = item.labels + self?.unselectedLabels = allLabels.filter { label in + !item.labels.contains(where: { $0.id == label.id }) + } + } + if let initiallySelectedLabels = initiallySelectedLabels { + self?.selectedLabels = initiallySelectedLabels + self?.unselectedLabels = allLabels.filter { label in + !initiallySelectedLabels.contains(where: { $0.id == label.id }) + } + } + } + ) + .store(in: &subscriptions) + } + + func createLabel(dataService: DataService, name: String, color: Color, description: String?) { + isLoading = true + + dataService.createLabelPublisher( + name: name, + color: color.hex ?? "", + description: description + ).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.labels.insert(result, at: 0) + self?.unselectedLabels.insert(result, at: 0) + self?.showCreateEmailModal = false + } + ) + .store(in: &subscriptions) + } + + func deleteLabel(dataService: DataService, labelID: String) { + isLoading = true + + dataService.removeLabelPublisher(labelID: labelID).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + self?.labels.removeAll { $0.id == labelID } + } + ) + .store(in: &subscriptions) + } + + func saveItemLabelChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) { + isLoading = true + dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { onComplete($0) } + ) + .store(in: &subscriptions) + } + + func addLabelToItem(_ label: FeedItemLabel) { + selectedLabels.insert(label, at: 0) + unselectedLabels.removeAll { $0.id == label.id } + } + + func removeLabelFromItem(_ label: FeedItemLabel) { + unselectedLabels.insert(label, at: 0) + selectedLabels.removeAll { $0.id == label.id } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index 06f94ea3c..2d83208f3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -92,7 +92,7 @@ final class LinkItemDetailViewModel: ObservableObject { case let .shareHighlight(highlightID): print("show share modal for highlight with id: \(highlightID)") case let .updateReadingProgess(progress: progress): - self?.homeFeedViewModel.updateProgress(itemID: self?.item.id ?? "", progress: Double(progress)) + self?.homeFeedViewModel.uncommittedReadingProgressUpdates[self?.item.id ?? ""] = Double(progress) } } .store(in: &newWebAppWrapperViewModel.subscriptions) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index ca2443130..80d6eef94 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -63,6 +63,12 @@ struct ProfileView: View { } Section { + if FeatureFlag.enableLabels { + NavigationLink(destination: LabelsView()) { + Text("Labels") + } + } + NavigationLink(destination: NewsletterEmailsView()) { Text("Emails") } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index 26ee03a8d..a4544fafb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -4,107 +4,109 @@ import Utils import Views import WebKit -struct WebReader: UIViewRepresentable { - let articleContent: ArticleContent - let item: FeedItem - let openLinkAction: (URL) -> Void - let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void - let navBarVisibilityRatioUpdater: (Double) -> Void +#if os(iOS) + struct WebReader: UIViewRepresentable { + let articleContent: ArticleContent + let item: FeedItem + let openLinkAction: (URL) -> Void + let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void + let navBarVisibilityRatioUpdater: (Double) -> Void - @Binding var increaseFontActionID: UUID? - @Binding var decreaseFontActionID: UUID? - @Binding var annotationSaveTransactionID: UUID? - @Binding var annotation: String + @Binding var increaseFontActionID: UUID? + @Binding var decreaseFontActionID: UUID? + @Binding var annotationSaveTransactionID: UUID? + @Binding var annotation: String - func makeCoordinator() -> WebReaderCoordinator { - WebReaderCoordinator() - } - - func fontSize() -> Int { - let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebFontSize.rawValue) - return storedSize <= 1 ? UITraitCollection.current.preferredWebFontSize : storedSize - } - - func makeUIView(context: Context) -> WKWebView { - let webView = WebViewManager.shared() - let contentController = WKUserContentController() - - webView.navigationDelegate = context.coordinator - webView.isOpaque = false - webView.backgroundColor = .clear - webView.configuration.userContentController = contentController - webView.scrollView.delegate = context.coordinator - webView.scrollView.contentInset.top = readerViewNavBarHeight - webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight - - webView.configuration.userContentController.removeAllScriptMessageHandlers() - - for action in WebViewAction.allCases { - webView.configuration.userContentController.add(context.coordinator, name: action.rawValue) + func makeCoordinator() -> WebReaderCoordinator { + WebReaderCoordinator() } - webView.configuration.userContentController.add(webView, name: "viewerAction") - - webView.configuration.userContentController.addScriptMessageHandler( - context.coordinator, contentWorld: .page, name: "articleAction" - ) - - context.coordinator.linkHandler = openLinkAction - context.coordinator.webViewActionHandler = webViewActionHandler - context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater - loadContent(webView: webView) - - return webView - } - - func updateUIView(_ webView: WKWebView, context: Context) { - if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID { - context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID - (webView as? WebView)?.saveAnnotation(annotation: annotation) + func fontSize() -> Int { + let storedSize = UserDefaults.standard.integer(forKey: UserDefaultKey.preferredWebFontSize.rawValue) + return storedSize <= 1 ? UITraitCollection.current.preferredWebFontSize : storedSize } - if increaseFontActionID != context.coordinator.previousIncreaseFontActionID { - context.coordinator.previousIncreaseFontActionID = increaseFontActionID - (webView as? WebView)?.increaseFontSize() - } + func makeUIView(context: Context) -> WKWebView { + let webView = WebViewManager.shared() + let contentController = WKUserContentController() - if decreaseFontActionID != context.coordinator.previousDecreaseFontActionID { - context.coordinator.previousDecreaseFontActionID = decreaseFontActionID - (webView as? WebView)?.decreaseFontSize() - } + webView.navigationDelegate = context.coordinator + webView.isOpaque = false + webView.backgroundColor = .clear + webView.configuration.userContentController = contentController + webView.scrollView.delegate = context.coordinator + webView.scrollView.contentInset.top = readerViewNavBarHeight + webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight - // If the webview had been terminated `needsReload` will have been set to true - if context.coordinator.needsReload { + webView.configuration.userContentController.removeAllScriptMessageHandlers() + + for action in WebViewAction.allCases { + webView.configuration.userContentController.add(context.coordinator, name: action.rawValue) + } + + webView.configuration.userContentController.add(webView, name: "viewerAction") + + webView.configuration.userContentController.addScriptMessageHandler( + context.coordinator, contentWorld: .page, name: "articleAction" + ) + + context.coordinator.linkHandler = openLinkAction + context.coordinator.webViewActionHandler = webViewActionHandler + context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater loadContent(webView: webView) - context.coordinator.needsReload = false - return + + return webView } - if webView.isLoading { return } + func updateUIView(_ webView: WKWebView, context: Context) { + if annotationSaveTransactionID != context.coordinator.lastSavedAnnotationID { + context.coordinator.lastSavedAnnotationID = annotationSaveTransactionID + (webView as? WebView)?.saveAnnotation(annotation: annotation) + } - // If the root element is not detected then `WKWebView` may have unloaded the content - // so we need to load it again. - webView.evaluateJavaScript("document.getElementById('root') ? true : false") { hasRootElement, _ in - guard let hasRootElement = hasRootElement as? Bool else { return } + if increaseFontActionID != context.coordinator.previousIncreaseFontActionID { + context.coordinator.previousIncreaseFontActionID = increaseFontActionID + (webView as? WebView)?.increaseFontSize() + } - if !hasRootElement { - DispatchQueue.main.async { - loadContent(webView: webView) + if decreaseFontActionID != context.coordinator.previousDecreaseFontActionID { + context.coordinator.previousDecreaseFontActionID = decreaseFontActionID + (webView as? WebView)?.decreaseFontSize() + } + + // If the webview had been terminated `needsReload` will have been set to true + if context.coordinator.needsReload { + loadContent(webView: webView) + context.coordinator.needsReload = false + return + } + + if webView.isLoading { return } + + // If the root element is not detected then `WKWebView` may have unloaded the content + // so we need to load it again. + webView.evaluateJavaScript("document.getElementById('root') ? true : false") { hasRootElement, _ in + guard let hasRootElement = hasRootElement as? Bool else { return } + + if !hasRootElement { + DispatchQueue.main.async { + loadContent(webView: webView) + } } } } - } - func loadContent(webView: WKWebView) { - webView.loadHTMLString( - WebReaderContent( - articleContent: articleContent, - item: item, - isDark: UITraitCollection.current.userInterfaceStyle == .dark, - fontSize: fontSize() + func loadContent(webView: WKWebView) { + webView.loadHTMLString( + WebReaderContent( + articleContent: articleContent, + item: item, + isDark: UITraitCollection.current.userInterfaceStyle == .dark, + fontSize: fontSize() + ) + .styledContent, + baseURL: ViewsPackage.bundleURL ) - .styledContent, - baseURL: ViewsPackage.bundleURL - ) + } } -} +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 503f67417..ecf6144a8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -5,278 +5,284 @@ import SwiftUI import Views import WebKit -struct WebReaderContainerView: View { - let item: FeedItem - let homeFeedViewModel: HomeFeedViewModel +#if os(iOS) + struct WebReaderContainerView: View { + let item: FeedItem + let homeFeedViewModel: HomeFeedViewModel - @State private var showFontSizePopover = false - @State var showHighlightAnnotationModal = false - @State var safariWebLink: SafariWebLink? - @State private var navBarVisibilityRatio = 1.0 - @State private var showDeleteConfirmation = false - @State private var showOverlay = true - @State var increaseFontActionID: UUID? - @State var decreaseFontActionID: UUID? - @State var annotationSaveTransactionID: UUID? - @State var annotation = String() + @State private var showFontSizePopover = false + @State var showHighlightAnnotationModal = false + @State var safariWebLink: SafariWebLink? + @State private var navBarVisibilityRatio = 1.0 + @State private var showDeleteConfirmation = false + @State private var showOverlay = true + @State var increaseFontActionID: UUID? + @State var decreaseFontActionID: UUID? + @State var annotationSaveTransactionID: UUID? + @State var annotation = String() - @EnvironmentObject var dataService: DataService - @Environment(\.presentationMode) var presentationMode: Binding - @StateObject var viewModel = WebReaderViewModel() + @EnvironmentObject var dataService: DataService + @Environment(\.presentationMode) var presentationMode: Binding + @StateObject var viewModel = WebReaderViewModel() - var fontAdjustmentPopoverView: some View { - FontSizeAdjustmentPopoverView( - increaseFontAction: { increaseFontActionID = UUID() }, - decreaseFontAction: { decreaseFontActionID = UUID() } - ) - } + var fontAdjustmentPopoverView: some View { + FontSizeAdjustmentPopoverView( + increaseFontAction: { increaseFontActionID = UUID() }, + decreaseFontAction: { decreaseFontActionID = UUID() } + ) + } - func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) { - if message.name == WebViewAction.readingProgressUpdate.rawValue { - let messageBody = message.body as? [String: Double] + func webViewActionHandler(message: WKScriptMessage, replyHandler: WKScriptMessageReplyHandler?) { + if message.name == WebViewAction.readingProgressUpdate.rawValue { + let messageBody = message.body as? [String: Double] - if let messageBody = messageBody, let progress = messageBody["progress"] { - homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress)) + if let messageBody = messageBody, let progress = messageBody["progress"] { + homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress) + } } - } - if let replyHandler = replyHandler { - viewModel.webViewActionWithReplyHandler( - message: message, - replyHandler: replyHandler, - dataService: dataService - ) - return - } - - if message.name == WebViewAction.highlightAction.rawValue { - handleHighlightAction(message: message) - } - - if message.name == WebViewAction.readingProgressUpdate.rawValue { - guard let messageBody = message.body as? [String: Double] else { return } - guard let progress = messageBody["progress"] else { return } - homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress)) - } - } - - private func handleHighlightAction(message: WKScriptMessage) { - guard let messageBody = message.body as? [String: String] else { return } - guard let actionID = messageBody["actionID"] else { return } - - switch actionID { - case "annotate": - annotation = messageBody["annotation"] ?? "" - showHighlightAnnotationModal = true - default: - break - } - } - - var navBariOS14: some View { - HStack(alignment: .center) { - Button( - action: { self.presentationMode.wrappedValue.dismiss() }, - label: { - Image(systemName: "chevron.backward") - .font(.appTitleTwo) - .foregroundColor(.appGrayTextContrast) - .padding(.horizontal) - } - ) - .scaleEffect(navBarVisibilityRatio) - Spacer() - Button( - action: { showFontSizePopover.toggle() }, - label: { - Image(systemName: "textformat.size") - .font(.appTitleTwo) - } - ) - .padding(.horizontal) - .scaleEffect(navBarVisibilityRatio) - } - .frame(height: readerViewNavBarHeight * navBarVisibilityRatio) - .opacity(navBarVisibilityRatio) - .background(Color.systemBackground) - .onTapGesture { - showFontSizePopover = false - } - } - - @available(macOS 12.0, *) - @available(iOS 15.0, *) - var navBar: some View { - HStack(alignment: .center) { - Button( - action: { self.presentationMode.wrappedValue.dismiss() }, - label: { - Image(systemName: "chevron.backward") - .font(.appTitleTwo) - .foregroundColor(.appGrayTextContrast) - .padding(.horizontal) - } - ) - .scaleEffect(navBarVisibilityRatio) - Spacer() - Button( - action: { showFontSizePopover.toggle() }, - label: { - Image(systemName: "textformat.size") - .font(.appTitleTwo) - } - ) - .padding(.horizontal) - .scaleEffect(navBarVisibilityRatio) - Menu( - content: { - Group { - Button( - action: { - homeFeedViewModel.setLinkArchived( - dataService: dataService, - linkId: item.id, - archived: !item.isArchived - ) - }, - label: { - Label( - item.isArchived ? "Unarchive" : "Archive", - systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" - ) - } - ) - Button( - action: { showDeleteConfirmation = true }, - label: { Label("Delete", systemImage: "trash") } - ) - } - }, - label: { - Image.profile - .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) { - homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id) - } - Button("Cancel", role: .cancel, action: {}) - } - } - - var body: some View { - ZStack { - if let articleContent = viewModel.articleContent { - WebReader( - articleContent: articleContent, - item: item, - openLinkAction: { - #if os(macOS) - NSWorkspace.shared.open($0) - #elseif os(iOS) - safariWebLink = SafariWebLink(id: UUID(), url: $0) - #endif - }, - webViewActionHandler: webViewActionHandler, - navBarVisibilityRatioUpdater: { - if $0 < 1 { - showFontSizePopover = false - } - navBarVisibilityRatio = $0 - }, - increaseFontActionID: $increaseFontActionID, - decreaseFontActionID: $decreaseFontActionID, - annotationSaveTransactionID: $annotationSaveTransactionID, - annotation: $annotation + if let replyHandler = replyHandler { + viewModel.webViewActionWithReplyHandler( + message: message, + replyHandler: replyHandler, + dataService: dataService ) - .overlay( - Group { - if showOverlay { - Color.systemBackground - .transition(.opacity) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { - withAnimation(.linear(duration: 0.2)) { - showOverlay = false + return + } + + if message.name == WebViewAction.highlightAction.rawValue { + handleHighlightAction(message: message) + } + + if message.name == WebViewAction.readingProgressUpdate.rawValue { + guard let messageBody = message.body as? [String: Double] else { return } + guard let progress = messageBody["progress"] else { return } + homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress) + } + } + + private func handleHighlightAction(message: WKScriptMessage) { + guard let messageBody = message.body as? [String: String] else { return } + guard let actionID = messageBody["actionID"] else { return } + + switch actionID { + case "annotate": + annotation = messageBody["annotation"] ?? "" + showHighlightAnnotationModal = true + default: + break + } + } + + var navBariOS14: some View { + HStack(alignment: .center) { + Button( + action: { self.presentationMode.wrappedValue.dismiss() }, + label: { + Image(systemName: "chevron.backward") + .font(.appTitleTwo) + .foregroundColor(.appGrayTextContrast) + .padding(.horizontal) + } + ) + .scaleEffect(navBarVisibilityRatio) + Spacer() + Button( + action: { showFontSizePopover.toggle() }, + label: { + Image(systemName: "textformat.size") + .font(.appTitleTwo) + } + ) + .padding(.horizontal) + .scaleEffect(navBarVisibilityRatio) + } + .frame(height: readerViewNavBarHeight * navBarVisibilityRatio) + .opacity(navBarVisibilityRatio) + .background(Color.systemBackground) + .onTapGesture { + showFontSizePopover = false + } + } + + @available(macOS 12.0, *) + @available(iOS 15.0, *) + var navBar: some View { + HStack(alignment: .center) { + Button( + action: { self.presentationMode.wrappedValue.dismiss() }, + label: { + Image(systemName: "chevron.backward") + .font(.appTitleTwo) + .foregroundColor(.appGrayTextContrast) + .padding(.horizontal) + } + ) + .scaleEffect(navBarVisibilityRatio) + Spacer() + Button( + action: { showFontSizePopover.toggle() }, + label: { + Image(systemName: "textformat.size") + .font(.appTitleTwo) + } + ) + .padding(.horizontal) + .scaleEffect(navBarVisibilityRatio) + Menu( + content: { + Group { + Button( + action: { homeFeedViewModel.itemUnderLabelEdit = item }, + label: { Label("Edit Labels", systemImage: "tag") } + ) + Button( + action: { + homeFeedViewModel.setLinkArchived( + dataService: dataService, + linkId: item.id, + archived: !item.isArchived + ) + }, + label: { + Label( + item.isArchived ? "Unarchive" : "Archive", + systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" + ) + } + ) + Button( + action: { showDeleteConfirmation = true }, + label: { Label("Delete", systemImage: "trash") } + ) + } + }, + label: { + Image.profile + .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) { + homeFeedViewModel.removeLink(dataService: dataService, linkId: item.id) + } + Button("Cancel", role: .cancel, action: {}) + } + } + + var body: some View { + ZStack { + if let articleContent = viewModel.articleContent { + WebReader( + articleContent: articleContent, + item: item, + openLinkAction: { + #if os(macOS) + NSWorkspace.shared.open($0) + #elseif os(iOS) + safariWebLink = SafariWebLink(id: UUID(), url: $0) + #endif + }, + webViewActionHandler: webViewActionHandler, + navBarVisibilityRatioUpdater: { + if $0 < 1 { + showFontSizePopover = false + } + navBarVisibilityRatio = $0 + }, + increaseFontActionID: $increaseFontActionID, + decreaseFontActionID: $decreaseFontActionID, + annotationSaveTransactionID: $annotationSaveTransactionID, + annotation: $annotation + ) + .overlay( + Group { + if showOverlay { + Color.systemBackground + .transition(.opacity) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + withAnimation(.linear(duration: 0.2)) { + showOverlay = false + } } } - } - } - } - ) - .sheet(item: $safariWebLink) { - SafariView(url: $0.url) - } - .sheet(isPresented: $showHighlightAnnotationModal) { - HighlightAnnotationSheet( - annotation: $annotation, - onSave: { - annotationSaveTransactionID = UUID() - showHighlightAnnotationModal = false - }, - onCancel: { - showHighlightAnnotationModal = false + } } ) - } - } else { - Color.clear - .contentShape(Rectangle()) - .onAppear { - if !viewModel.isLoading { - viewModel.loadContent(dataService: dataService, slug: item.slug) - } + .sheet(item: $safariWebLink) { + SafariView(url: $0.url) } - } - if showFontSizePopover { - VStack { + .sheet(isPresented: $showHighlightAnnotationModal) { + HighlightAnnotationSheet( + annotation: $annotation, + onSave: { + annotationSaveTransactionID = UUID() + showHighlightAnnotationModal = false + }, + onCancel: { + showHighlightAnnotationModal = false + } + ) + } + } else { Color.clear .contentShape(Rectangle()) - .frame(height: LinkItemDetailView.navBarHeight) - HStack { + .onAppear { + if !viewModel.isLoading { + viewModel.loadContent(dataService: dataService, slug: item.slug) + } + } + } + if showFontSizePopover { + VStack { + Color.clear + .contentShape(Rectangle()) + .frame(height: LinkItemDetailView.navBarHeight) + HStack { + Spacer() + fontAdjustmentPopoverView + .background(Color.appButtonBackground) + .cornerRadius(8) + .padding(.trailing, 44) + } Spacer() - fontAdjustmentPopoverView - .background(Color.appButtonBackground) - .cornerRadius(8) - .padding(.trailing, 44) } - Spacer() + .background( + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + showFontSizePopover = false + } + ) } - .background( - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - showFontSizePopover = false - } - ) - } - if #available(iOS 15.0, *) { - VStack(spacing: 0) { - navBar - Spacer() + if #available(iOS 15.0, *) { + VStack(spacing: 0) { + navBar + Spacer() + } + .navigationBarHidden(true) + } else { + VStack(spacing: 0) { + navBariOS14 + Spacer() + } + .navigationBarHidden(true) } - .navigationBarHidden(true) - } else { - VStack(spacing: 0) { - navBariOS14 - Spacer() - } - .navigationBarHidden(true) - } - }.onDisappear { - // Clear the shared webview content when exiting - WebViewManager.shared().loadHTMLString("", baseURL: nil) + }.onDisappear { + // Clear the shared webview content when exiting + WebViewManager.shared().loadHTMLString("", baseURL: nil) + } + .navigationBarHidden(true) } - .navigationBarHidden(true) } -} +#endif diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift index c75bca135..54f7edb5b 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderCoordinator.swift @@ -2,7 +2,9 @@ import Combine import Models import Services import SwiftUI -import UIKit +#if os(iOS) + import UIKit +#endif import Utils import Views import WebKit diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 1dd5aa8dd..2afceda35 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -10,24 +10,28 @@ struct SafariWebLink: Identifiable { } func encodeHighlightResult(_ highlight: Highlight) -> [String: Any]? { - let data = try? JSONEncoder().encode(highlight) - if let data = data, let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] { - return dictionary - } - return nil + guard let data = try? JSONEncoder().encode(highlight) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] } final class WebReaderViewModel: ObservableObject { @Published var isLoading = false @Published var articleContent: ArticleContent? + var slug: String? var subscriptions = Set() func loadContent(dataService: DataService, slug: String) { + self.slug = slug isLoading = true guard let viewer = dataService.currentViewer else { return } + if let content = dataService.pageFromCache(slug: slug) { + articleContent = content + // continue to load from the web if possible + } + dataService.articleContentPublisher(username: viewer.username, slug: slug).sink( receiveCompletion: { [weak self] completion in guard case .failure = completion else { return } @@ -35,6 +39,7 @@ final class WebReaderViewModel: ObservableObject { }, receiveValue: { [weak self] articleContent in self?.articleContent = articleContent + dataService.pageCache.setObject(CachedPageContent(slug, articleContent), forKey: NSString(string: slug)) } ) .store(in: &subscriptions) @@ -167,12 +172,16 @@ final class WebReaderViewModel: ObservableObject { switch actionID { case "deleteHighlight": + dataService.invalidateCachedPage(slug: slug) deleteHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) case "createHighlight": + dataService.invalidateCachedPage(slug: slug) createHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) case "mergeHighlight": + dataService.invalidateCachedPage(slug: slug) mergeHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) case "updateHighlight": + dataService.invalidateCachedPage(slug: slug) updateHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) case "articleReadingProgress": updateReadingProgress(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) diff --git a/apple/OmnivoreKit/Sources/Models/ArticleContent.swift b/apple/OmnivoreKit/Sources/Models/ArticleContent.swift index bc55cf570..032ccf434 100644 --- a/apple/OmnivoreKit/Sources/Models/ArticleContent.swift +++ b/apple/OmnivoreKit/Sources/Models/ArticleContent.swift @@ -1,5 +1,15 @@ import Foundation +public class CachedPageContent: NSObject { + public let slug: String + public let value: ArticleContent + + public init(_ slug: String, _ content: ArticleContent) { + self.slug = slug + self.value = content + } +} + public struct ArticleContent { public let htmlContent: String public let highlights: [Highlight] diff --git a/apple/OmnivoreKit/Sources/Models/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/FeedItem.swift index c8f5a418e..42ff019ea 100644 --- a/apple/OmnivoreKit/Sources/Models/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/FeedItem.swift @@ -12,7 +12,6 @@ public struct HomeFeedData { public struct FeedItem: Identifiable, Hashable, Decodable { public let id: String - public let renderID = UUID() public let title: String public let createdAt: Date public let savedAt: Date @@ -29,6 +28,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable { public let slug: String public let isArchived: Bool public let contentReader: String? + public var labels: [FeedItemLabel] public init( id: String, @@ -47,7 +47,8 @@ public struct FeedItem: Identifiable, Hashable, Decodable { publishDate: Date?, slug: String, isArchived: Bool, - contentReader: String? + contentReader: String?, + labels: [FeedItemLabel] ) { self.id = id self.title = title @@ -66,10 +67,12 @@ public struct FeedItem: Identifiable, Hashable, Decodable { self.slug = slug self.isArchived = isArchived self.contentReader = contentReader + self.labels = labels } enum CodingKeys: String, CodingKey { - case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url + // swiftlint:disable:next line_length + case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url, labels } public init(from decoder: Decoder) throws { @@ -86,6 +89,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable { contentReader = try container.decode(String.self, forKey: .contentReader) pageURLString = try container.decode(String.self, forKey: .url) isArchived = try container.decode(Bool.self, forKey: .isArchived) + labels = try container.decode([FeedItemLabel].self, forKey: .labels) self.onDeviceImageURLString = nil self.documentDirectoryPath = nil diff --git a/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift b/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift new file mode 100644 index 000000000..14a157a02 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct FeedItemLabel: Decodable, Hashable { + public let id: String + public let name: String + public let color: String + public let createdAt: Date? + public let description: String? + + public init( + id: String, + name: String, + color: String, + createdAt: Date?, + description: String? + ) { + self.id = id + self.name = name + self.color = color + self.createdAt = createdAt + self.description = description + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 12deb1da3..12e72e406 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -1,6 +1,16 @@ +import Combine import Foundation import Models +public class CacheManager: NSObject, NSCacheDelegate { + public func cache(_: NSCache, willEvictObject obj: Any) { + // This is just used for debugging + if let content = obj as? CachedPageContent { + print("evicting page from cache", content.slug) + } + } +} + public final class DataService: ObservableObject { public static var registerIntercomUser: ((String) -> Void)? public static var showIntercomMessenger: (() -> Void)? @@ -9,12 +19,20 @@ public final class DataService: ObservableObject { public internal(set) var currentViewer: Viewer? let networker: Networker + public let pageCache = NSCache() + let pageCacheQueue = DispatchQueue.global(qos: .background) + let highlightsCache = NSCache() let highlightsCacheQueue = DispatchQueue(label: "app.omnivore.highlights.cache.queue", attributes: .concurrent) + let cacheManager: CacheManager + var subscriptions = Set() + public init(appEnvironment: AppEnvironment, networker: Networker) { self.appEnvironment = appEnvironment self.networker = networker + self.cacheManager = CacheManager() + pageCache.delegate = cacheManager } public func clearHighlights() { @@ -30,3 +48,37 @@ public final class DataService: ObservableObject { } } } + +public extension DataService { + func prefetchPages(items: [FeedItem]) { + print("prefetching pages") + guard let viewer = currentViewer else { return } + + for item in items { + let slug = item.slug + articleContentPublisher(username: viewer.username, slug: slug).sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] articleContent in + self?.pageCache.setObject(CachedPageContent(slug, articleContent), forKey: NSString(string: slug)) + } + ) + .store(in: &subscriptions) + } + } + + func pageFromCache(slug: String) -> ArticleContent? { + if let content = pageCache.object(forKey: NSString(string: slug)) { + print("cache hit", slug) + return content.value + } else { + print("cache miss", slug) + } + return nil + } + + func invalidateCachedPage(slug: String?) { + if let slug = slug { + pageCache.removeObject(forKey: NSString(string: slug)) + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 89528dbc1..162b121e0 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -2970,8 +2970,11 @@ extension Objects { let savedByViewer: [String: Bool] let shareInfo: [String: Objects.LinkShareInfo] let sharedComment: [String: String] + let siteIcon: [String: String] + let siteName: [String: String] let slug: [String: String] let title: [String: String] + let uploadFileId: [String: String] let url: [String: String] enum TypeName: String, Codable { @@ -3088,6 +3091,14 @@ extension Objects.Article: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "siteIcon": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "siteName": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "slug": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -3096,6 +3107,10 @@ extension Objects.Article: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "uploadFileId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "url": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -3134,8 +3149,11 @@ extension Objects.Article: Decodable { savedByViewer = map["savedByViewer"] shareInfo = map["shareInfo"] sharedComment = map["sharedComment"] + siteIcon = map["siteIcon"] + siteName = map["siteName"] slug = map["slug"] title = map["title"] + uploadFileId = map["uploadFileId"] url = map["url"] } } @@ -3587,6 +3605,51 @@ extension Fields where TypeLock == Objects.Article { return selection.mock() } } + + func uploadFileId() throws -> String? { + let field = GraphQLField.leaf( + name: "uploadFileId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.uploadFileId[field.alias!] + case .mocking: + return nil + } + } + + func siteName() throws -> String? { + let field = GraphQLField.leaf( + name: "siteName", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.siteName[field.alias!] + case .mocking: + return nil + } + } + + func siteIcon() throws -> String? { + let field = GraphQLField.leaf( + name: "siteIcon", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.siteIcon[field.alias!] + case .mocking: + return nil + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -5114,7 +5177,6 @@ extension Objects { struct Highlight { let __typename: TypeName = .highlight let annotation: [String: String] - let article: [String: Objects.Article] let createdAt: [String: DateTime] let createdByMe: [String: Bool] let id: [String: String] @@ -5151,10 +5213,6 @@ extension Objects.Highlight: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } - case "article": - if let value = try container.decode(Objects.Article?.self, forKey: codingKey) { - map.set(key: field, hash: alias, value: value as Any) - } case "createdAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -5218,7 +5276,6 @@ extension Objects.Highlight: Decodable { } annotation = map["annotation"] - article = map["article"] createdAt = map["createdAt"] createdByMe = map["createdByMe"] id = map["id"] @@ -5291,25 +5348,6 @@ extension Fields where TypeLock == Objects.Highlight { } } - func article(selection: Selection) throws -> Type { - let field = GraphQLField.composite( - name: "article", - arguments: [], - selection: selection.selection - ) - select(field) - - switch response { - case let .decoding(data): - if let data = data.article[field.alias!] { - return try selection.decode(data: data) - } - throw HttpError.badpayload - case .mocking: - return selection.mock() - } - } - func quote() throws -> String { let field = GraphQLField.leaf( name: "quote", @@ -10784,7 +10822,7 @@ extension Fields where TypeLock == Objects.Label { } } - func createdAt() throws -> DateTime { + func createdAt() throws -> DateTime? { let field = GraphQLField.leaf( name: "createdAt", arguments: [] @@ -10793,12 +10831,9 @@ extension Fields where TypeLock == Objects.Label { switch response { case let .decoding(data): - if let data = data.createdAt[field.alias!] { - return data - } - throw HttpError.badpayload + return data.createdAt[field.alias!] case .mocking: - return DateTime.mockValue + return nil } } } @@ -11200,6 +11235,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteLabelError = Selection } +extension Objects { + struct UpdateLabelSuccess { + let __typename: TypeName = .updateLabelSuccess + let label: [String: Objects.Label] + + enum TypeName: String, Codable { + case updateLabelSuccess = "UpdateLabelSuccess" + } + } +} + +extension Objects.UpdateLabelSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "label": + if let value = try container.decode(Objects.Label?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + label = map["label"] + } +} + +extension Fields where TypeLock == Objects.UpdateLabelSuccess { + func label(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "label", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.label[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelSuccess = Selection +} + +extension Objects { + struct UpdateLabelError { + let __typename: TypeName = .updateLabelError + let errorCodes: [String: [Enums.UpdateLabelErrorCode]] + + enum TypeName: String, Codable { + case updateLabelError = "UpdateLabelError" + } + } +} + +extension Objects.UpdateLabelError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdateLabelErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.UpdateLabelError { + func errorCodes() throws -> [Enums.UpdateLabelErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelError = Selection +} + extension Objects { struct SignupSuccess { let __typename: TypeName = .signupSuccess @@ -11462,6 +11628,910 @@ extension Selection where TypeLock == Never, Type == Never { typealias SetLabelsError = Selection } +extension Objects { + struct GenerateApiKeySuccess { + let __typename: TypeName = .generateApiKeySuccess + let apiKey: [String: String] + + enum TypeName: String, Codable { + case generateApiKeySuccess = "GenerateApiKeySuccess" + } + } +} + +extension Objects.GenerateApiKeySuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "apiKey": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + apiKey = map["apiKey"] + } +} + +extension Fields where TypeLock == Objects.GenerateApiKeySuccess { + func apiKey() throws -> String { + let field = GraphQLField.leaf( + name: "apiKey", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.apiKey[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeySuccess = Selection +} + +extension Objects { + struct GenerateApiKeyError { + let __typename: TypeName = .generateApiKeyError + let errorCodes: [String: [Enums.GenerateApiKeyErrorCode]] + + enum TypeName: String, Codable { + case generateApiKeyError = "GenerateApiKeyError" + } + } +} + +extension Objects.GenerateApiKeyError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.GenerateApiKeyErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.GenerateApiKeyError { + func errorCodes() throws -> [Enums.GenerateApiKeyErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeyError = Selection +} + +extension Objects { + struct SearchItem { + let __typename: TypeName = .searchItem + let annotation: [String: String] + let author: [String: String] + let contentReader: [String: Enums.ContentReader] + let createdAt: [String: DateTime] + let description: [String: String] + let id: [String: String] + let image: [String: String] + let isArchived: [String: Bool] + let labels: [String: [Objects.Label]] + let originalArticleUrl: [String: String] + let ownedByViewer: [String: Bool] + let pageId: [String: String] + let pageType: [String: Enums.PageType] + let publishedAt: [String: DateTime] + let quote: [String: String] + let readingProgressAnchorIndex: [String: Int] + let readingProgressPercent: [String: Double] + let shortId: [String: String] + let slug: [String: String] + let title: [String: String] + let uploadFileId: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case searchItem = "SearchItem" + } + } +} + +extension Objects.SearchItem: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "annotation": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "author": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "contentReader": + if let value = try container.decode(Enums.ContentReader?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "image": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "isArchived": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "labels": + if let value = try container.decode([Objects.Label]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "originalArticleUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "ownedByViewer": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageType": + if let value = try container.decode(Enums.PageType?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "publishedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "quote": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "readingProgressAnchorIndex": + if let value = try container.decode(Int?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "readingProgressPercent": + if let value = try container.decode(Double?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "shortId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "slug": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "title": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "uploadFileId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + annotation = map["annotation"] + author = map["author"] + contentReader = map["contentReader"] + createdAt = map["createdAt"] + description = map["description"] + id = map["id"] + image = map["image"] + isArchived = map["isArchived"] + labels = map["labels"] + originalArticleUrl = map["originalArticleUrl"] + ownedByViewer = map["ownedByViewer"] + pageId = map["pageId"] + pageType = map["pageType"] + publishedAt = map["publishedAt"] + quote = map["quote"] + readingProgressAnchorIndex = map["readingProgressAnchorIndex"] + readingProgressPercent = map["readingProgressPercent"] + shortId = map["shortId"] + slug = map["slug"] + title = map["title"] + uploadFileId = map["uploadFileId"] + url = map["url"] + } +} + +extension Fields where TypeLock == Objects.SearchItem { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func title() throws -> String { + let field = GraphQLField.leaf( + name: "title", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.title[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func slug() throws -> String { + let field = GraphQLField.leaf( + name: "slug", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.slug[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func url() throws -> String { + let field = GraphQLField.leaf( + name: "url", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.url[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func pageType() throws -> Enums.PageType { + let field = GraphQLField.leaf( + name: "pageType", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageType[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.PageType.allCases.first! + } + } + + func contentReader() throws -> Enums.ContentReader { + let field = GraphQLField.leaf( + name: "contentReader", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.contentReader[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.ContentReader.allCases.first! + } + } + + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func isArchived() throws -> Bool { + let field = GraphQLField.leaf( + name: "isArchived", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.isArchived[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Bool.mockValue + } + } + + func readingProgressPercent() throws -> Double? { + let field = GraphQLField.leaf( + name: "readingProgressPercent", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.readingProgressPercent[field.alias!] + case .mocking: + return nil + } + } + + func readingProgressAnchorIndex() throws -> Int? { + let field = GraphQLField.leaf( + name: "readingProgressAnchorIndex", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.readingProgressAnchorIndex[field.alias!] + case .mocking: + return nil + } + } + + func author() throws -> String? { + let field = GraphQLField.leaf( + name: "author", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.author[field.alias!] + case .mocking: + return nil + } + } + + func image() throws -> String? { + let field = GraphQLField.leaf( + name: "image", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.image[field.alias!] + case .mocking: + return nil + } + } + + func description() throws -> String? { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.description[field.alias!] + case .mocking: + return nil + } + } + + func publishedAt() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "publishedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.publishedAt[field.alias!] + case .mocking: + return nil + } + } + + func ownedByViewer() throws -> Bool? { + let field = GraphQLField.leaf( + name: "ownedByViewer", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.ownedByViewer[field.alias!] + case .mocking: + return nil + } + } + + func originalArticleUrl() throws -> String? { + let field = GraphQLField.leaf( + name: "originalArticleUrl", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.originalArticleUrl[field.alias!] + case .mocking: + return nil + } + } + + func uploadFileId() throws -> String? { + let field = GraphQLField.leaf( + name: "uploadFileId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.uploadFileId[field.alias!] + case .mocking: + return nil + } + } + + func pageId() throws -> String? { + let field = GraphQLField.leaf( + name: "pageId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.pageId[field.alias!] + case .mocking: + return nil + } + } + + func shortId() throws -> String? { + let field = GraphQLField.leaf( + name: "shortId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.shortId[field.alias!] + case .mocking: + return nil + } + } + + func quote() throws -> String? { + let field = GraphQLField.leaf( + name: "quote", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.quote[field.alias!] + case .mocking: + return nil + } + } + + func annotation() throws -> String? { + let field = GraphQLField.leaf( + name: "annotation", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.annotation[field.alias!] + case .mocking: + return nil + } + } + + func labels(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "labels", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.labels[field.alias!]) + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchItem = Selection +} + +extension Objects { + struct SearchItemEdge { + let __typename: TypeName = .searchItemEdge + let cursor: [String: String] + let node: [String: Objects.SearchItem] + + enum TypeName: String, Codable { + case searchItemEdge = "SearchItemEdge" + } + } +} + +extension Objects.SearchItemEdge: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "cursor": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "node": + if let value = try container.decode(Objects.SearchItem?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + cursor = map["cursor"] + node = map["node"] + } +} + +extension Fields where TypeLock == Objects.SearchItemEdge { + func cursor() throws -> String { + let field = GraphQLField.leaf( + name: "cursor", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.cursor[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func node(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "node", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.node[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchItemEdge = Selection +} + +extension Objects { + struct SearchSuccess { + let __typename: TypeName = .searchSuccess + let edges: [String: [Objects.SearchItemEdge]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case searchSuccess = "SearchSuccess" + } + } +} + +extension Objects.SearchSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "edges": + if let value = try container.decode([Objects.SearchItemEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + edges = map["edges"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Objects.SearchSuccess { + func edges(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "edges", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.edges[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func pageInfo(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "pageInfo", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageInfo[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchSuccess = Selection +} + +extension Objects { + struct SearchError { + let __typename: TypeName = .searchError + let errorCodes: [String: [Enums.SearchErrorCode]] + + enum TypeName: String, Codable { + case searchError = "SearchError" + } + } +} + +extension Objects.SearchError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SearchErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.SearchError { + func errorCodes() throws -> [Enums.SearchErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchError = Selection +} + extension Objects { struct Mutation { let __typename: TypeName = .mutation @@ -11479,6 +12549,7 @@ extension Objects { let deleteNewsletterEmail: [String: Unions.DeleteNewsletterEmailResult] let deleteReaction: [String: Unions.DeleteReactionResult] let deleteReminder: [String: Unions.DeleteReminderResult] + let generateApiKey: [String: Unions.GenerateApiKeyResult] let googleLogin: [String: Unions.LoginResult] let googleSignup: [String: Unions.GoogleSignupResult] let logOut: [String: Unions.LogOutResult] @@ -11500,6 +12571,7 @@ extension Objects { let signup: [String: Unions.SignupResult] let updateHighlight: [String: Unions.UpdateHighlightResult] let updateHighlightReply: [String: Unions.UpdateHighlightReplyResult] + let updateLabel: [String: Unions.UpdateLabelResult] let updateLinkShareInfo: [String: Unions.UpdateLinkShareInfoResult] let updateReminder: [String: Unions.UpdateReminderResult] let updateSharedComment: [String: Unions.UpdateSharedCommentResult] @@ -11581,6 +12653,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "generateApiKey": + if let value = try container.decode(Unions.GenerateApiKeyResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "googleLogin": if let value = try container.decode(Unions.LoginResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11665,6 +12741,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.UpdateHighlightReplyResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "updateLabel": + if let value = try container.decode(Unions.UpdateLabelResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "updateLinkShareInfo": if let value = try container.decode(Unions.UpdateLinkShareInfoResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11713,6 +12793,7 @@ extension Objects.Mutation: Decodable { deleteNewsletterEmail = map["deleteNewsletterEmail"] deleteReaction = map["deleteReaction"] deleteReminder = map["deleteReminder"] + generateApiKey = map["generateApiKey"] googleLogin = map["googleLogin"] googleSignup = map["googleSignup"] logOut = map["logOut"] @@ -11734,6 +12815,7 @@ extension Objects.Mutation: Decodable { signup = map["signup"] updateHighlight = map["updateHighlight"] updateHighlightReply = map["updateHighlightReply"] + updateLabel = map["updateLabel"] updateLinkShareInfo = map["updateLinkShareInfo"] updateReminder = map["updateReminder"] updateSharedComment = map["updateSharedComment"] @@ -12447,6 +13529,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func updateLabel(input: InputObjects.UpdateLabelInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "updateLabel", + arguments: [Argument(name: "input", type: "UpdateLabelInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.updateLabel[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func deleteLabel(id: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "deleteLabel", @@ -12522,6 +13623,25 @@ extension Fields where TypeLock == Objects.Mutation { return selection.mock() } } + + func generateApiKey(scope: OptionalArgument = .absent(), selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "generateApiKey", + arguments: [Argument(name: "scope", type: "String", value: scope)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.generateApiKey[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -12543,6 +13663,7 @@ extension Objects { let me: [String: Objects.User] let newsletterEmails: [String: Unions.NewsletterEmailsResult] let reminder: [String: Unions.ReminderResult] + let search: [String: Unions.SearchResult] let sharedArticle: [String: Unions.SharedArticleResult] let user: [String: Unions.UserResult] let users: [String: Unions.UsersResult] @@ -12614,6 +13735,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.ReminderResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "search": + if let value = try container.decode(Unions.SearchResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "sharedArticle": if let value = try container.decode(Unions.SharedArticleResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -12652,6 +13777,7 @@ extension Objects.Query: Decodable { me = map["me"] newsletterEmails = map["newsletterEmails"] reminder = map["reminder"] + search = map["search"] sharedArticle = map["sharedArticle"] user = map["user"] users = map["users"] @@ -12955,6 +14081,25 @@ extension Fields where TypeLock == Objects.Query { return selection.mock() } } + + func search(after: OptionalArgument = .absent(), first: OptionalArgument = .absent(), query: OptionalArgument = .absent(), selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "search", + arguments: [Argument(name: "after", type: "String", value: after), Argument(name: "first", type: "Int", value: first), Argument(name: "query", type: "String", value: query)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.search[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -16598,6 +17743,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteLabelResult = Selection } +extension Unions { + struct UpdateLabelResult { + let __typename: TypeName + let errorCodes: [String: [Enums.UpdateLabelErrorCode]] + let label: [String: Objects.Label] + + enum TypeName: String, Codable { + case updateLabelSuccess = "UpdateLabelSuccess" + case updateLabelError = "UpdateLabelError" + } + } +} + +extension Unions.UpdateLabelResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.UpdateLabelErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "label": + if let value = try container.decode(Objects.Label?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + label = map["label"] + } +} + +extension Fields where TypeLock == Unions.UpdateLabelResult { + func on(updateLabelSuccess: Selection, updateLabelError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "UpdateLabelSuccess", selection: updateLabelSuccess.selection), GraphQLField.fragment(type: "UpdateLabelError", selection: updateLabelError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .updateLabelSuccess: + let data = Objects.UpdateLabelSuccess(label: data.label) + return try updateLabelSuccess.decode(data: data) + case .updateLabelError: + let data = Objects.UpdateLabelError(errorCodes: data.errorCodes) + return try updateLabelError.decode(data: data) + } + case .mocking: + return updateLabelSuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias UpdateLabelResult = Selection +} + extension Unions { struct SignupResult { let __typename: TypeName @@ -16746,6 +17965,160 @@ extension Selection where TypeLock == Never, Type == Never { typealias SetLabelsResult = Selection } +extension Unions { + struct GenerateApiKeyResult { + let __typename: TypeName + let apiKey: [String: String] + let errorCodes: [String: [Enums.GenerateApiKeyErrorCode]] + + enum TypeName: String, Codable { + case generateApiKeySuccess = "GenerateApiKeySuccess" + case generateApiKeyError = "GenerateApiKeyError" + } + } +} + +extension Unions.GenerateApiKeyResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "apiKey": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.GenerateApiKeyErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + apiKey = map["apiKey"] + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Unions.GenerateApiKeyResult { + func on(generateApiKeySuccess: Selection, generateApiKeyError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GenerateApiKeySuccess", selection: generateApiKeySuccess.selection), GraphQLField.fragment(type: "GenerateApiKeyError", selection: generateApiKeyError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .generateApiKeySuccess: + let data = Objects.GenerateApiKeySuccess(apiKey: data.apiKey) + return try generateApiKeySuccess.decode(data: data) + case .generateApiKeyError: + let data = Objects.GenerateApiKeyError(errorCodes: data.errorCodes) + return try generateApiKeyError.decode(data: data) + } + case .mocking: + return generateApiKeySuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GenerateApiKeyResult = Selection +} + +extension Unions { + struct SearchResult { + let __typename: TypeName + let edges: [String: [Objects.SearchItemEdge]] + let errorCodes: [String: [Enums.SearchErrorCode]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case searchSuccess = "SearchSuccess" + case searchError = "SearchError" + } + } +} + +extension Unions.SearchResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "edges": + if let value = try container.decode([Objects.SearchItemEdge]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.SearchErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + edges = map["edges"] + errorCodes = map["errorCodes"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Unions.SearchResult { + func on(searchSuccess: Selection, searchError: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SearchSuccess", selection: searchSuccess.selection), GraphQLField.fragment(type: "SearchError", selection: searchError.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .searchSuccess: + let data = Objects.SearchSuccess(edges: data.edges, pageInfo: data.pageInfo) + return try searchSuccess.decode(data: data) + case .searchError: + let data = Objects.SearchError(errorCodes: data.errorCodes) + return try searchError.decode(data: data) + } + case .mocking: + return searchSuccess.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SearchResult = Selection +} + // MARK: - Enums enum Enums {} @@ -16779,6 +18152,10 @@ extension Enums { /// SortBy enum SortBy: String, CaseIterable, Codable { case updatedTime = "UPDATED_TIME" + + case score = "SCORE" + + case savedAt = "SAVED_AT" } } @@ -16893,6 +18270,8 @@ extension Enums { case website = "WEBSITE" + case highlights = "HIGHLIGHTS" + case unknown = "UNKNOWN" } } @@ -16956,6 +18335,8 @@ extension Enums { case payloadTooLarge = "PAYLOAD_TOO_LARGE" case uploadFileMissing = "UPLOAD_FILE_MISSING" + + case elasticError = "ELASTIC_ERROR" } } @@ -17355,6 +18736,19 @@ extension Enums { } } +extension Enums { + /// UpdateLabelErrorCode + enum UpdateLabelErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case forbidden = "FORBIDDEN" + } +} + extension Enums { /// SetLabelsErrorCode enum SetLabelsErrorCode: String, CaseIterable, Codable { @@ -17366,6 +18760,20 @@ extension Enums { } } +extension Enums { + /// GenerateApiKeyErrorCode + enum GenerateApiKeyErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + } +} + +extension Enums { + /// SearchErrorCode + enum SearchErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + } +} + // MARK: - Input Objects enum InputObjects {} @@ -18227,6 +19635,33 @@ extension InputObjects { } } +extension InputObjects { + struct UpdateLabelInput: Encodable, Hashable { + var labelId: String + + var color: String + + var description: OptionalArgument = .absent() + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(labelId, forKey: .labelId) + try container.encode(color, forKey: .color) + if description.hasValue { try container.encode(description, forKey: .description) } + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case labelId + case color + case description + case name + } + } +} + extension InputObjects { struct LoginInput: Encodable, Hashable { var password: String @@ -18283,18 +19718,18 @@ extension InputObjects { extension InputObjects { struct SetLabelsInput: Encodable, Hashable { - var linkId: String + var pageId: String var labelIds: [String] func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(linkId, forKey: .linkId) + try container.encode(pageId, forKey: .pageId) try container.encode(labelIds, forKey: .labelIds) } enum CodingKeys: String, CodingKey { - case linkId + case pageId case labelIds } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift new file mode 100644 index 000000000..6400f87cf --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift @@ -0,0 +1,62 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func createLabelPublisher( + name: String, + color: String, + description: String? + ) -> AnyPublisher { + enum MutationResult { + case saved(label: FeedItemLabel) + case error(errorCode: Enums.CreateLabelErrorCode) + } + + let selection = Selection { + try $0.on( + createLabelSuccess: .init { .saved(label: try $0.label(selection: feedItemLabelSelection)) }, + createLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) } + ) + } + + let mutation = Selection.Mutation { + try $0.createLabel( + input: InputObjects.CreateLabelInput( + name: name, + color: color, + description: OptionalArgument(description) + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(mutation, to: path, headers: headers) { result in + switch result { + case let .success(payload): + if let graphqlError = payload.errors { + promise(.failure(.message(messageText: "graphql error: \(graphqlError)"))) + } + + switch payload.data { + case let .saved(label: label): + promise(.success(label)) + case let .error(errorCode: errorCode): + promise(.failure(.message(messageText: errorCode.rawValue))) + } + case .failure: + promise(.failure(.message(messageText: "graphql error"))) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift new file mode 100644 index 000000000..134954a4f --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift @@ -0,0 +1,53 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func removeLabelPublisher(labelID: String) -> AnyPublisher { + enum MutationResult { + case success(labelID: String) + case error(errorCode: Enums.DeleteLabelErrorCode) + } + + let selection = Selection { + try $0.on( + deleteLabelSuccess: .init { + .success(labelID: try $0.label(selection: Selection.Label { try $0.id() })) + }, + deleteLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) } + ) + } + + let mutation = Selection.Mutation { + try $0.deleteLabel(id: labelID, selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(mutation, to: path, headers: headers) { result in + switch result { + case let .success(payload): + if payload.errors != nil { + promise(.failure(.message(messageText: "Error removing label"))) + } + + switch payload.data { + case .success: + promise(.success(true)) + case .error: + promise(.failure(.message(messageText: "Error removing label"))) + } + case .failure: + promise(.failure(.message(messageText: "Error removing label"))) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift new file mode 100644 index 000000000..0809b693a --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift @@ -0,0 +1,57 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func updateArticleLabelsPublisher(itemID: String, labelIDs: [String]) -> AnyPublisher<[FeedItemLabel], BasicError> { + enum MutationResult { + case saved(feedItem: [FeedItemLabel]) + case error(errorCode: Enums.SetLabelsErrorCode) + } + + let selection = Selection { + try $0.on( + setLabelsSuccess: .init { .saved(feedItem: try $0.labels(selection: feedItemLabelSelection.list)) }, + setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) } + ) + } + + let mutation = Selection.Mutation { + try $0.setLabels( + input: InputObjects.SetLabelsInput( + pageId: itemID, + labelIds: labelIDs + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(mutation, to: path, headers: headers) { result in + switch result { + case let .success(payload): + if let graphqlError = payload.errors { + promise(.failure(.message(messageText: graphqlError.first.debugDescription))) + } + + switch payload.data { + case let .saved(labels): + promise(.success(labels)) + case .error: + promise(.failure(.message(messageText: "failed to set labels"))) + } + case .failure: + promise(.failure(.message(messageText: "failed to set labels"))) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index 4970c2330..8822a0d98 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -3,7 +3,6 @@ import Foundation import Models import SwiftGraphQL -// swiftlint:disable:next function_body_length public extension DataService { func articleContentPublisher(username: String, slug: String) -> AnyPublisher { enum QueryResult { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift new file mode 100644 index 000000000..5cce49647 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift @@ -0,0 +1,49 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func labelsPublisher() -> AnyPublisher<[FeedItemLabel], ServerError> { + enum QueryResult { + case success(result: [FeedItemLabel]) + case error(error: String) + } + + let selection = Selection { + try $0.on(labelsSuccess: .init { + QueryResult.success(result: try $0.labels(selection: feedItemLabelSelection.list)) + }, + labelsError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }) + } + + let query = Selection.Query { + try $0.labels(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(query, to: path, headers: headers) { result in + switch result { + case let .success(payload): + switch payload.data { + case let .success(result: result): + promise(.success(result)) + case .error: + promise(.failure(.unknown)) + } + case .failure: + promise(.failure(.unknown)) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift index 7419e2649..532ffe88f 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift @@ -143,7 +143,8 @@ let homeFeedItemSelection = Selection.Article { publishDate: try $0.publishedAt()?.value, slug: try $0.slug(), isArchived: try $0.isArchived(), - contentReader: try $0.contentReader().rawValue + contentReader: try $0.contentReader().rawValue, + labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift new file mode 100644 index 000000000..998dc8dea --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift @@ -0,0 +1,12 @@ +import Models +import SwiftGraphQL + +let feedItemLabelSelection = Selection.Label { + FeedItemLabel( + id: try $0.id(), + name: try $0.name(), + color: try $0.color(), + createdAt: try $0.createdAt()?.value, + description: try $0.description() + ) +} diff --git a/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift b/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift new file mode 100644 index 000000000..a676666c5 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift @@ -0,0 +1,87 @@ +import SwiftUI + +public extension Color { + /// Inititializes a `Color` from a hex value + /// - Parameter hex: Color hex value. ex: `#FFFFFF` + /// + init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + + var red: CGFloat = 0.0 + var green: CGFloat = 0.0 + var blue: CGFloat = 0.0 + var alpha: CGFloat = 1.0 + + let length = hexSanitized.count + + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } + + if length == 6 { + red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 + green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 + blue = CGFloat(rgb & 0x0000FF) / 255.0 + } else if length == 8 { + red = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0 + green = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0 + blue = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0 + alpha = CGFloat(rgb & 0x0000_00FF) / 255.0 + + } else { + return nil + } + + self.init(red: red, green: green, blue: blue, opacity: alpha) + } + + var hex: String? { + if let hexValue = toHex() { + return "#\(hexValue)" + } else { + return nil + } + } + + private func toHex() -> String? { + #if os(iOS) + guard let components = UIColor(self).cgColor.components, components.count >= 3 else { + return nil + } + #endif + + #if os(macOS) + guard let components = NSColor(self).cgColor.components, components.count >= 3 else { + return nil + } + #endif + let red = Float(components[0]) + let green = Float(components[1]) + let blue = Float(components[2]) + + return String( + format: "%02lX%02lX%02lX", + lroundf(red * 255), + lroundf(green * 255), + lroundf(blue * 255) + ) + } + + var isDark: Bool { + #if os(iOS) + guard let components = UIColor(self).cgColor.components, components.count >= 3 else { + return false + } + #endif + + #if os(macOS) + guard let components = NSColor(self).cgColor.components, components.count >= 3 else { + return false + } + #endif + + let lum = 0.2126 * Float(components[0]) + 0.7152 * Float(components[1]) + 0.0722 * Float(components[2]) + return lum < 0.50 + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index a06f1d9e8..ab5196f5a 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -14,6 +14,6 @@ public enum FeatureFlag { public static let enablePushNotifications = false public static let enableShareButton = false public static let enableSnooze = false - public static let showFeedItemTags = false + public static let enableLabels = true public static let useLocalWebView = true } diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift b/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift index 07e458365..a78344f6e 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/WebAppView.swift @@ -10,7 +10,9 @@ enum WebViewConfigurationManager { static func create() -> WKWebViewConfiguration { let config = WKWebViewConfiguration() config.processPool = processPool - config.allowsInlineMediaPlayback = true + #if os(iOS) + config.allowsInlineMediaPlayback = true + #endif config.mediaTypesRequiringUserActionForPlayback = .audio return config } diff --git a/apple/OmnivoreKit/Sources/Views/Article/WebView.swift b/apple/OmnivoreKit/Sources/Views/Article/WebView.swift index 9744d2f9a..5a6277f00 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/WebView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/WebView.swift @@ -55,7 +55,7 @@ public final class WebView: WKWebView { } #elseif os(macOS) - override func viewDidChangeEffectiveAppearance() { + override public func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) { case .some(.darkAqua): diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 03c20e65d..bdd97a4ad 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -22,6 +22,7 @@ public extension Color { static var systemBackground: Color { Color(.systemBackground) } static var systemPlaceholder: Color { Color(.placeholderText) } static var secondarySystemGroupedBackground: Color { Color(.secondarySystemGroupedBackground) } + static var systemGray6: Color { Color(.systemGray6) } static var systemLabel: Color { if #available(iOS 15.0, *) { return Color(uiColor: .label) @@ -34,6 +35,7 @@ public extension Color { static var systemBackground: Color { Color(.windowBackgroundColor) } static var systemPlaceholder: Color { Color(.placeholderTextColor) } static var systemLabel: Color { Color(.labelColor) } + static var systemGray6: Color { Color(NSColor.systemGray) } // Just for compilation. secondarySystemGroupedBackground shouldn't be used on macOS static var secondarySystemGroupedBackground: Color { Color(.windowBackgroundColor) } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index e098badda..55fef0e3b 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -5,6 +5,7 @@ import Utils public enum GridCardAction { case toggleArchiveStatus case delete + case editLabels } public struct GridCard: View { @@ -42,6 +43,10 @@ public struct GridCard: View { var contextMenuView: some View { Group { + Button( + action: { menuActionHandler(.editLabels) }, + label: { Label("Edit Labels", systemImage: "tag") } + ) Button( action: { menuActionHandler(.toggleArchiveStatus) }, label: { @@ -156,11 +161,12 @@ public struct GridCard: View { .onTapGesture { tapHandler() } // Category Labels - if FeatureFlag.showFeedItemTags { + if FeatureFlag.enableLabels { ScrollView(.horizontal, showsIndicators: false) { HStack { - TextChip(text: "label", color: .red) - TextChip(text: "longer label", color: .blue) + ForEach(item.labels, id: \.self) { + TextChip(feedItemLabel: $0) + } Spacer() } .frame(height: 30) diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 775ad6854..c07ce3463 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -1,22 +1,113 @@ +import Models import SwiftUI +import Utils + +public struct TextChip: View { + public init(text: String, color: Color) { + self.text = text + self.color = color + } + + public init?(feedItemLabel: FeedItemLabel) { + guard let color = Color(hex: feedItemLabel.color) else { return nil } + + self.text = feedItemLabel.name + self.color = color + } -struct TextChip: View { let text: String let color: Color let cornerRadius = 20.0 - var body: some View { + public var body: some View { Text(text) .padding(.horizontal, 10) .padding(.vertical, 5) .font(.appFootnote) - .foregroundColor(color) + .foregroundColor(color.isDark ? .white : .black) .lineLimit(1) - .background(color.opacity(0.1)) + .background(color) .cornerRadius(cornerRadius) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(color.opacity(0.3), lineWidth: 1) - ) + } +} + +public struct TextChipButton: View { + public static func makeAddLabelButton(onTap: @escaping () -> Void) -> TextChipButton { + TextChipButton(title: "Labels", color: .systemGray6, actionType: .show, onTap: onTap) + } + + public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton { + TextChipButton(title: title, color: .appButtonBackground, actionType: .add, onTap: onTap) + } + + public static func makeRemovableLabelButton( + feedItemLabel: FeedItemLabel, + onTap: @escaping () -> Void + ) -> TextChipButton { + TextChipButton( + title: feedItemLabel.name, + color: Color(hex: feedItemLabel.color) ?? .appButtonBackground, + actionType: .remove, + onTap: onTap + ) + } + + public enum ActionType { + case remove + case add + case show + + var systemIconName: String { + switch self { + case .remove: + return "xmark" + case .add: + return "plus" + case .show: + return "chevron.down" + } + } + } + + init(title: String, color: Color, actionType: ActionType, onTap: @escaping () -> Void) { + self.text = title + self.color = color + self.onTap = onTap + self.actionType = actionType + self.foregroundColor = { + if actionType == .show { + return .appGrayText + } + return color.isDark ? .white : .black + }() + } + + let text: String + let color: Color + let onTap: () -> Void + let actionType: ActionType + let cornerRadius = 20.0 + let foregroundColor: Color + + public var body: some View { + Button(action: onTap) { + VStack(spacing: 0) { + HStack { + Text(text) + .padding(.leading, 3) + Image(systemName: actionType.systemIconName) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .font(.appFootnote) + .foregroundColor(foregroundColor) + .lineLimit(1) + .background(color) + .cornerRadius(cornerRadius) + + Color.clear.contentShape(Rectangle()).frame(height: 15) + } + .contentShape(Rectangle()) + } } } diff --git a/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift b/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift new file mode 100644 index 000000000..f4800a5d1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Utils/ToolbarItemPlacementExtension.swift @@ -0,0 +1,19 @@ +import SwiftUI + +public extension ToolbarItemPlacement { + static var barLeading: ToolbarItemPlacement { + #if os(iOS) + .navigationBarLeading + #else + .automatic + #endif + } + + static var barTrailing: ToolbarItemPlacement { + #if os(iOS) + .navigationBarTrailing + #else + .automatic + #endif + } +} diff --git a/apple/swiftgraphql.yml b/apple/swiftgraphql.yml index 9a2b8bd2b..dd96fa3fc 100644 --- a/apple/swiftgraphql.yml +++ b/apple/swiftgraphql.yml @@ -4,10 +4,12 @@ scalars: SanitizedString_undefined_15: String SanitizedString_undefined_40: String SanitizedString_undefined_50: String + SanitizedString_undefined_64: String SanitizedString_undefined_95: String + SanitizedString_undefined_100: String SanitizedString_undefined_300: String SanitizedString_undefined_400: String SanitizedString_undefined_2000: String SanitizedString_undefined_4000: String SanitizedString_undefined_8000: String - SanitizedString_undefined_undefined: String \ No newline at end of file + SanitizedString_undefined_undefined: String diff --git a/docker-compose-test.yml b/docker-compose-test.yml new file mode 100644 index 000000000..8772ffc50 --- /dev/null +++ b/docker-compose-test.yml @@ -0,0 +1,73 @@ +version: '3' +services: + postgres-test: + image: "postgres:12.8" + container_name: "omnivore-postgres-test" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=omnivore_test + - PG_POOL_MAX=20 + healthcheck: + test: "exit 0" + interval: 2s + timeout: 12s + retries: 3 + expose: + - 5432 + + elastic-test: + image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1 + container_name: "omnivore-elastic-test" + healthcheck: + test: curl 0.0.0.0:9201/_cat/health >/dev/null || exit 1 + interval: 2s + timeout: 2s + retries: 5 + environment: + - discovery.type=single-node + - http.cors.allow-origin=* + - http.cors.enabled=true + - http.cors.allow-headers=X-Requested-With,X-Auth-Token,Content-Type,Content-Length,Authorization + - http.cors.allow-credentials=true + - http.port=9201 + volumes: + - ./.docker/elastic-data:/usr/share/elasticsearch/data + ports: + - "9201:9201" + + api-test: + build: + context: . + dockerfile: ./packages/api/Dockerfile-test + container_name: "omnivore-api-test" + environment: + - API_ENV=local + - PG_HOST=postgres-test + - PG_USER=postgres + - PG_PASSWORD=postgres + - PG_DB=omnivore_test + - PG_PORT=5432 + - PG_POOL_MAX=20 + - ELASTIC_URL=http://elastic-test:9201 + - IMAGE_PROXY_URL=http://localhost:9999 + - IMAGE_PROXY_SECRET=some-secret + - JWT_SECRET=some_secret + - SSO_JWT_SECRET=some_sso_secret + - CLIENT_URL=http://localhost:3000 + - GATEWAY_URL=http://localhost:8080/api + - PUPPETEER_TASK_HANDLER_URL=http://localhost:9090/ + - REMINDER_TASK_HANDLER_URL=/svc/reminders/trigger + - BOOKMARKLET_JWT_SECRET=some_bookmarklet_secret + - BOOKMARKLET_VERSION=1.0.0 + - PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper' + - GCP_PROJECT_ID=omnivore-local + - GAUTH_CLIENT_ID='notset' + - GAUTH_SECRET='notset' + - SEGMENT_WRITE_KEY='test' + - PUBSUB_VERIFICATION_TOKEN='123456' + depends_on: + postgres-test: + condition: service_healthy + elastic-test: + condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index f773466f6..78301c35b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,7 @@ services: - PG_PORT=5432 - PG_POOL_MAX=20 - ELASTIC_URL=http://elastic:9200 + - JAEGER_HOST=jaeger - IMAGE_PROXY_URL=http://localhost:9999 - IMAGE_PROXY_SECRET=some-secret - JWT_SECRET=some_secret diff --git a/package.json b/package.json index f42ccfb0e..4985ac42d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ ], "license": "UNLICENSED", "scripts": { - "test": "lerna run test --ignore @omnivore/web", + "test": "lerna run --no-bail test --ignore @omnivore/web", "lint": "lerna run lint --ignore @omnivore/web", "build": "lerna run build --ignore @omnivore/web", "bootstrap": "lerna bootstrap", diff --git a/packages/api/.env.example b/packages/api/.env.example index 2590b876d..7c3204150 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -23,4 +23,5 @@ GCS_UPLOAD_BUCKET= GCS_UPLOAD_SA_KEY_FILE_PATH= TWITTER_BEARER_TOKEN= PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper' -REMINDER_TASK_HANDLER_URL= \ No newline at end of file +REMINDER_TASK_HANDLER_URL= +ELASTIC_URL=http://localhost:9200 diff --git a/packages/api/.env.test b/packages/api/.env.test index cb55a1b02..4d10b8316 100644 --- a/packages/api/.env.test +++ b/packages/api/.env.test @@ -27,3 +27,5 @@ PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper' SEGMENT_WRITE_KEY='test' REMINDER_TASK_HANDLER_URL=http://localhost:4000/svc/reminders/trigger PUBSUB_VERIFICATION_TOKEN='123456' +PUPPETEER_TASK_HANDLER_URL=http://localhost:9090/ +ELASTIC_URL=http://localhost:9200 diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 603154477..377397b16 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -2,7 +2,6 @@ FROM node:14.18-alpine as builder WORKDIR /app -ENV NODE_ENV production ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true COPY package.json . @@ -14,7 +13,7 @@ COPY .eslintrc . COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json COPY /packages/api/package.json ./packages/api/package.json -RUN yarn install --pure-lockfile --production +RUN yarn install --pure-lockfile ADD /packages/readabilityjs ./packages/readabilityjs ADD /packages/api ./packages/api @@ -22,7 +21,10 @@ ADD /packages/api ./packages/api RUN yarn RUN yarn workspace @omnivore/api build - +# After building, fetch the production dependencies +RUN rm -rf /app/packages/api/node_modules +RUN rm -rf /app/node_modules +RUN yarn install --pure-lockfile --production FROM node:14.18-alpine as runner @@ -38,7 +40,7 @@ COPY --from=builder /app/packages/api/package.json /app/packages/api/package.jso COPY --from=builder /app/packages/api/node_modules /app/packages/api/node_modules COPY --from=builder /app/node_modules /app/node_modules COPY --from=builder /app/package.json /app/package.json - +COPY --from=builder /app/packages/api/index_settings.json /app/packages/api/index_settings.json EXPOSE 8080 CMD ["yarn", "workspace", "@omnivore/api", "start"] diff --git a/packages/api/Dockerfile-test b/packages/api/Dockerfile-test index 262cb5a29..bdeb25b6d 100644 --- a/packages/api/Dockerfile-test +++ b/packages/api/Dockerfile-test @@ -18,6 +18,7 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true RUN yarn install +ADD /packages/db ./packages/db ADD /packages/readabilityjs ./packages/readabilityjs ADD /packages/api ./packages/api diff --git a/packages/api/add_highlight_to_elastic.py b/packages/api/add_highlight_to_elastic.py new file mode 100644 index 000000000..46d73ba8a --- /dev/null +++ b/packages/api/add_highlight_to_elastic.py @@ -0,0 +1,188 @@ +#!/usr/bin/python +import os +import json +import psycopg2 +from psycopg2.extras import RealDictCursor +from elasticsearch import Elasticsearch, NotFoundError + +PG_HOST = os.getenv('PG_HOST', 'localhost') +PG_PORT = os.getenv('PG_PORT', 5432) +PG_USER = os.getenv('PG_USER', 'app_user') +PG_PASSWORD = os.getenv('PG_PASSWORD', 'app_pass') +PG_DB = os.getenv('PG_DB', 'omnivore') +ES_URL = os.getenv('ES_URL', 'http://localhost:9200') +ES_USERNAME = os.getenv('ES_USERNAME', 'elastic') +ES_PASSWORD = os.getenv('ES_PASSWORD', 'password') +UPDATE_TIME = os.getenv('UPDATE_TIME', '2019-01-01 00:00:00') +INDEX_SETTINGS = os.getenv('INDEX_SETTINGS', 'index_settings.json') +DATETIME_FORMAT = 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + + +def update_mappings(client: Elasticsearch): + print('updating mappings') + try: + with open(INDEX_SETTINGS, 'r') as f: + settings = json.load(f) + client.indices.put_mapping( + index='pages_alias', + body=settings['mappings']) + print('mappings updated') + except Exception as err: + print('update mappings ERROR:', err) + exit(1) + + +def assertData(conn, client: Elasticsearch, pages): + # get all users from postgres + try: + success = 0 + failure = 0 + skip = 0 + cursor = conn.cursor(cursor_factory=RealDictCursor) + for page in pages: + pageId = page['pageId'] + cursor.execute( + f'''SELECT COUNT(*) FROM omnivore.highlight + WHERE elastic_page_id = \'{pageId}\' AND deleted = false''') + countInPostgres = cursor.fetchone()['count'] + try: + countInElastic = len(client.get( + index='pages_alias', + id=pageId, + _source=['highlights'])['_source']['highlights']) + except NotFoundError as err: + print('Elasticsearch get ERROR:', err) + # if page is not found in elasticsearch, skip testing + skip += 1 + continue + + if countInPostgres == countInElastic: + success += 1 + print(f'Page {pageId} OK') + else: + failure += 1 + print( + f'Page {pageId} ERROR: postgres: {countInPostgres}, elastic: {countInElastic}') + cursor.close() + print( + f'Asserted data, success: {success}, failure: {failure}, skip: {skip}') + except Exception as err: + print('Assert data ERROR:', err) + exit(1) + + +def ingest_highlights(conn, pages): + try: + import_count = 0 + cursor = conn.cursor(cursor_factory=RealDictCursor) + + for page in pages: + pageId = page['pageId'] + query = ''' + SELECT + id, + quote, + prefix, + to_char(created_at, '{DATETIME_FORMAT}') as "createdAt", + to_char(COALESCE(updated_at, current_timestamp), '{DATETIME_FORMAT}') as "updatedAt", + suffix, + patch, + annotation, + short_id as "shortId", + user_id as "userId", + to_char(shared_at, '{DATETIME_FORMAT}') as "sharedAt" + FROM omnivore.highlight + WHERE + elastic_page_id = \'{pageId}\' + AND deleted = false + AND created_at > '{UPDATE_TIME}' + '''.format(pageId=pageId, DATETIME_FORMAT=DATETIME_FORMAT, UPDATE_TIME=UPDATE_TIME) + + cursor.execute(query) + result = cursor.fetchall() + import_count += import_highlights_to_es(client, result, pageId) + + print(f'Imported total {import_count} highlights to es') + + cursor.close() + except Exception as err: + print('Export data to json ERROR:', err) + + +def import_highlights_to_es(client, highlights, pageId) -> int: + # import highlights to elasticsearch + print(f'Writing {len(highlights)} highlights to page {pageId}') + + if len(highlights) == 0: + print('No highlights to import') + return 0 + + try: + resp = client.update( + index='pages_alias', + id=pageId, + body={'doc': {'highlights': highlights}}) + + count = 0 + if resp['result'] == 'updated': + count = len(highlights) + + print(f'Added {count} highlights to page {pageId}') + + return count + except Exception as err: + print('Elasticsearch update ERROR:', err) + return 0 + + +def get_pages_with_highlights(conn): + try: + query = f''' + SELECT DISTINCT + elastic_page_id as "pageId" + FROM omnivore.highlight + WHERE + elastic_page_id IS NOT NULL + AND deleted = false + AND created_at > '{UPDATE_TIME}' + ''' + cursor = conn.cursor(cursor_factory=RealDictCursor) + cursor.execute(query) + result = cursor.fetchall() + cursor.close() + print('Found pages with highlights:', len(result)) + return result + except Exception as err: + print('Get pages with highlights ERROR:', err) + + +print('Starting migration') + +# test elastic client +client = Elasticsearch(ES_URL, http_auth=( + ES_USERNAME, ES_PASSWORD), retry_on_timeout=True) +try: + print('Elasticsearch client connected', client.info()) +except Exception as err: + print('Elasticsearch client ERROR:', err) + exit(1) + +# test postgres client +conn = psycopg2.connect( + f'host={PG_HOST} port={PG_PORT} dbname={PG_DB} user={PG_USER} \ + password={PG_PASSWORD}') +print('Postgres connection:', conn.info) + + +update_mappings(client) + +pages = get_pages_with_highlights(conn) + +ingest_highlights(conn, pages) + +assertData(conn, client, pages) + +client.close() +conn.close() + +print('Migration complete') diff --git a/packages/api/elastic_migrate.py b/packages/api/elastic_migrate.py index d0be65ede..3fd7003d5 100644 --- a/packages/api/elastic_migrate.py +++ b/packages/api/elastic_migrate.py @@ -89,7 +89,7 @@ def assertData(conn, client): f'SELECT COUNT(*) FROM omnivore.links WHERE user_id = \'{userId}\'''') countInPostgres = cursor.fetchone()['count'] countInElastic = client.count( - index='pages', body={'query': {'term': {'userId': userId}}})['count'] + index='pages_alias', body={'query': {'term': {'userId': userId}}})['count'] if countInPostgres == countInElastic: success += 1 @@ -197,7 +197,7 @@ def import_data_to_es(client, docs) -> int: doc['publishedAt'] = validated_date(doc['publishedAt']) # convert the string to a dict object dict_doc = { - '_index': 'pages', + '_index': 'pages_alias', '_id': doc['id'], '_source': doc } diff --git a/packages/api/index_settings.json b/packages/api/index_settings.json index c0c3aab57..2e540dcde 100644 --- a/packages/api/index_settings.json +++ b/packages/api/index_settings.json @@ -56,6 +56,30 @@ } } }, + "highlights": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "userId": { + "type": "keyword" + }, + "quote": { + "type": "text", + "analyzer": "strip_html_analyzer" + }, + "annotation": { + "type": "text" + }, + "createdAt": { + "type": "date" + }, + "updatedAt": { + "type": "date" + } + } + }, "readingProgressPercent": { "type": "float" }, diff --git a/packages/api/package.json b/packages/api/package.json index 3fdc6ded2..22541d7ae 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -8,7 +8,7 @@ "start": "node dist/server.js", "lint": "eslint src --ext ts,js,tsx,jsx", "lint:fix": "eslint src --fix --ext ts,js,tsx,jsx", - "test": "nyc mocha -r ts-node/register --config mocha-config.json --exit --timeout 10000" + "test": "nyc mocha -r ts-node/register --config mocha-config.json --timeout 10000" }, "dependencies": { "@elastic/elasticsearch": "~7.12.0", @@ -30,32 +30,12 @@ "@opentelemetry/instrumentation-pg": "^0.24.0", "@opentelemetry/node": "^0.24.0", "@opentelemetry/resources": "^0.24.0", - "@opentelemetry/semantic-conventions": "^0.24.0", + "@opentelemetry/semantic-conventions": "^1.0.1", "@opentelemetry/tracing": "^0.24.0", "@sendgrid/mail": "^7.6.0", "@sentry/integrations": "^6.19.1", "@sentry/node": "^5.26.0", "@sentry/tracing": "^5.26.0", - "@types/analytics-node": "^3.1.7", - "@types/bcryptjs": "^2.4.2", - "@types/chai": "^4.2.18", - "@types/chai-string": "^1.4.2", - "@types/cookie": "^0.4.0", - "@types/cookie-parser": "^1.4.2", - "@types/dompurify": "^2.0.4", - "@types/express": "^4.17.7", - "@types/highlightjs": "^9.12.2", - "@types/intercom-client": "^2.11.8", - "@types/jsdom": "^16.2.3", - "@types/jsonwebtoken": "^8.5.0", - "@types/luxon": "^1.25.0", - "@types/mocha": "^8.2.2", - "@types/oauth": "^0.9.1", - "@types/sanitize-html": "^1.27.1", - "@types/supertest": "^2.0.11", - "@types/urlsafe-base64": "^1.0.28", - "@types/uuid": "^8.3.0", - "@types/voca": "^1.4.0", "analytics-node": "^6.0.0", "apollo-datasource": "^3.3.1", "apollo-server-express": "^3.6.3", @@ -70,7 +50,7 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "firebase-admin": "^10.0.2", - "googleapis": "^59.0.0", + "googleapis": "^100.0.0", "graphql": "^15.3.0", "graphql-middleware": "^6.0.10", "graphql-shield": "^7.5.0", @@ -82,8 +62,9 @@ "jwks-rsa": "^2.0.3", "knex": "0.21.12", "knex-stringcase": "^1.4.2", - "luxon": "^1.25.0", + "luxon": "^2.3.1", "nanoid": "^3.1.25", + "nodemailer": "^6.7.3", "normalize-url": "^6.1.0", "oauth": "^0.9.15", "pg": "^8.3.3", @@ -94,8 +75,8 @@ "snake-case": "^3.0.3", "supertest": "^6.2.2", "ts-loader": "^8.0.3", - "typeorm": "^0.2.37", - "typeorm-naming-strategies": "^2.0.0", + "typeorm": "^0.3.4", + "typeorm-naming-strategies": "^4.1.0", "urlsafe-base64": "^1.0.0", "uuid": "^8.3.1", "voca": "^1.4.0", @@ -104,10 +85,29 @@ "devDependencies": { "@babel/register": "^7.14.5", "@istanbuljs/nyc-config-typescript": "^1.0.2", - "@types/analytics-node": "^3.1.7", "@types/highlightjs": "^9.12.2", "@types/nanoid": "^3.0.0", "@types/private-ip": "^1.0.0", + "@types/analytics-node": "^3.1.7", + "@types/bcryptjs": "^2.4.2", + "@types/chai": "^4.2.18", + "@types/chai-string": "^1.4.2", + "@types/cookie": "^0.4.0", + "@types/cookie-parser": "^1.4.2", + "@types/dompurify": "^2.0.4", + "@types/express": "^4.17.7", + "@types/intercom-client": "^2.11.8", + "@types/jsdom": "^16.2.3", + "@types/jsonwebtoken": "^8.5.0", + "@types/luxon": "^1.25.0", + "@types/mocha": "^8.2.2", + "@types/nodemailer": "^6.4.4", + "@types/oauth": "^0.9.1", + "@types/sanitize-html": "^1.27.1", + "@types/supertest": "^2.0.11", + "@types/urlsafe-base64": "^1.0.28", + "@types/uuid": "^8.3.0", + "@types/voca": "^1.4.0", "chai": "^4.3.4", "chai-string": "^1.5.0", "circular-dependency-plugin": "^5.2.0", @@ -121,6 +121,7 @@ "node": "14.18.x" }, "volta": { - "node": "14.18.x" + "node": "14.18.1", + "yarn": "1.22.18" } } diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index fa869a3c3..53c94ec24 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -115,6 +115,7 @@ export function makeApolloServer(): ApolloServer { schema: schema, context: contextFunc, formatError: (err) => { + console.log('server error', err) Sentry.captureException(err) // hide error messages from frontend on prod return new Error('Unexpected server error') diff --git a/packages/api/src/datalayer/links/share_info.ts b/packages/api/src/datalayer/links/share_info.ts index 4bc45acd3..2646cfc57 100644 --- a/packages/api/src/datalayer/links/share_info.ts +++ b/packages/api/src/datalayer/links/share_info.ts @@ -3,7 +3,7 @@ import Knex from 'knex' import { LinkShareInfo } from '../../generated/graphql' import { DataModels } from '../../resolvers/types' -import { getPageByParam } from '../../elastic' +import { getPageByParam } from '../../elastic/pages' // once we have links setup properly in the API we will remove this method // and have a getShareInfoForLink method diff --git a/packages/api/src/datalayer/pubsub.ts b/packages/api/src/datalayer/pubsub.ts index 690e9376b..e4f397acd 100644 --- a/packages/api/src/datalayer/pubsub.ts +++ b/packages/api/src/datalayer/pubsub.ts @@ -2,7 +2,6 @@ import { PubSub } from '@google-cloud/pubsub' import { env } from '../env' import { ReportType } from '../generated/graphql' import express from 'express' -import { Page } from '../elastic/types' export const createPubSubClient = (): PubsubClient => { const client = new PubSub() @@ -37,17 +36,35 @@ export const createPubSubClient = (): PubsubClient => { Buffer.from(JSON.stringify({ userId, email, name, username })) ) }, - pageUpdated: (page: Partial, userId: string): Promise => { + entityCreated: ( + type: EntityType, + data: T, + userId: string + ): Promise => { return publish( - 'pageUpdated', - Buffer.from(JSON.stringify({ ...page, userId })) + 'entityCreated', + Buffer.from(JSON.stringify({ type, userId, ...data })) ) }, - pageCreated: (page: Page): Promise => { - return publish('pageCreated', Buffer.from(JSON.stringify(page))) + entityUpdated: ( + type: EntityType, + data: T, + userId: string + ): Promise => { + return publish( + 'entityUpdated', + Buffer.from(JSON.stringify({ type, userId, ...data })) + ) }, - pageDeleted: (id: string, userId: string): Promise => { - return publish('pageDeleted', Buffer.from(JSON.stringify({ id, userId }))) + entityDeleted: ( + type: EntityType, + id: string, + userId: string + ): Promise => { + return publish( + 'entityDeleted', + Buffer.from(JSON.stringify({ type, id, userId })) + ) }, reportSubmitted: ( submitterId: string, @@ -65,6 +82,12 @@ export const createPubSubClient = (): PubsubClient => { } } +export enum EntityType { + PAGE = 'page', + HIGHLIGHT = 'highlight', + LABEL = 'label', +} + export interface PubsubClient { userCreated: ( userId: string, @@ -72,9 +95,9 @@ export interface PubsubClient { name: string, username: string ) => Promise - pageCreated: (page: Page) => Promise - pageUpdated: (page: Partial, userId: string) => Promise - pageDeleted: (id: string, userId: string) => Promise + entityCreated: (type: EntityType, data: T, userId: string) => Promise + entityUpdated: (type: EntityType, data: T, userId: string) => Promise + entityDeleted: (type: EntityType, id: string, userId: string) => Promise reportSubmitted( submitterId: string | undefined, itemUrl: string, diff --git a/packages/api/src/directives.ts b/packages/api/src/directives.ts index fd54cbb66..435166539 100644 --- a/packages/api/src/directives.ts +++ b/packages/api/src/directives.ts @@ -1,32 +1,41 @@ -import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils' +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' import { GraphQLNonNull, GraphQLScalarType, GraphQLSchema } from 'graphql' import { SanitizedString } from './scalars' export const sanitizeDirectiveTransformer = (schema: GraphQLSchema) => { return mapSchema(schema, { [MapperKind.FIELD]: (fieldConfig) => { - const sanitizeDirective = getDirective(schema, fieldConfig, 'sanitize') - if (!sanitizeDirective || sanitizeDirective.length < 1) { + const sanitizeDirective = getDirective( + schema, + fieldConfig, + 'sanitize' + )?.[0] + if (!sanitizeDirective) { return fieldConfig } - const maxLength = sanitizeDirective[0].maxLength as number | undefined - const allowedTags = sanitizeDirective[0].allowedTags as - | string[] - | undefined + const maxLength = sanitizeDirective.maxLength as number | undefined + const allowedTags = sanitizeDirective.allowedTags as string[] | undefined + const pattern = sanitizeDirective.pattern as string | undefined if ( fieldConfig.type instanceof GraphQLNonNull && fieldConfig.type.ofType instanceof GraphQLScalarType ) { fieldConfig.type = new GraphQLNonNull( - new SanitizedString(fieldConfig.type.ofType, allowedTags, maxLength) + new SanitizedString( + fieldConfig.type.ofType, + allowedTags, + maxLength, + pattern + ) ) } else if (fieldConfig.type instanceof GraphQLScalarType) { fieldConfig.type = new SanitizedString( fieldConfig.type, allowedTags, - maxLength + maxLength, + pattern ) } else { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/packages/api/src/elastic/highlights.ts b/packages/api/src/elastic/highlights.ts new file mode 100644 index 000000000..8277f7b9b --- /dev/null +++ b/packages/api/src/elastic/highlights.ts @@ -0,0 +1,302 @@ +import { + Highlight, + Page, + PageContext, + SearchItem, + SearchResponse, +} from './types' +import { ResponseError } from '@elastic/elasticsearch/lib/errors' +import { client, INDEX_ALIAS } from './index' +import { SortBy, SortOrder, SortParams } from '../utils/search' +import { EntityType } from '../datalayer/pubsub' + +export const addHighlightToPage = async ( + id: string, + highlight: Highlight, + ctx: PageContext +): Promise => { + try { + const { body } = await client.update({ + index: INDEX_ALIAS, + id, + body: { + script: { + source: `if (ctx._source.highlights == null) { + ctx._source.highlights = [params.highlight] + } else { + ctx._source.highlights.add(params.highlight) + }`, + lang: 'painless', + params: { + highlight: highlight, + }, + }, + }, + refresh: ctx.refresh, + retry_on_conflict: 3, + }) + + if (body.result !== 'updated') return false + + await ctx.pubsub.entityCreated( + EntityType.HIGHLIGHT, + highlight, + ctx.uid + ) + + return true + } catch (e) { + if ( + e instanceof ResponseError && + e.message === 'document_missing_exception' + ) { + console.log('page has been deleted', id) + return false + } + console.error('failed to add highlight to a page in elastic', e) + return false + } +} + +export const getHighlightById = async ( + id: string +): Promise => { + try { + const { body } = await client.search({ + index: INDEX_ALIAS, + body: { + query: { + nested: { + path: 'highlights', + query: { + match: { + 'highlights.id': id, + }, + }, + inner_hits: {}, + }, + }, + _source: false, + }, + }) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (body.hits.total.value === 0) { + return undefined + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return + return body.hits.hits[0].inner_hits.highlights.hits.hits[0]._source + } catch (e) { + console.error('failed to get highlight from a page in elastic', e) + return undefined + } +} + +export const deleteHighlight = async ( + highlightId: string, + ctx: PageContext +): Promise => { + try { + const { body } = await client.updateByQuery({ + index: INDEX_ALIAS, + body: { + script: { + source: + 'ctx._source.highlights.removeIf(h -> h.id == params.highlightId)', + lang: 'painless', + params: { + highlightId: highlightId, + }, + }, + query: { + bool: { + filter: [ + { + term: { + userId: ctx.uid, + }, + }, + { + nested: { + path: 'highlights', + query: { + term: { + 'highlights.id': highlightId, + }, + }, + }, + }, + ], + }, + }, + }, + refresh: ctx.refresh, + }) + + if (body.updated === 0) return false + + await ctx.pubsub.entityDeleted(EntityType.HIGHLIGHT, highlightId, ctx.uid) + + return true + } catch (e) { + console.error('failed to delete a highlight in elastic', e) + + return false + } +} + +export const searchHighlights = async ( + args: { + from?: number + size?: number + sort?: SortParams + query?: string + }, + userId: string +): Promise<[SearchItem[], number] | undefined> => { + try { + const { from = 0, size = 10, sort, query } = args + const sortOrder = sort?.order || SortOrder.DESCENDING + // default sort by updatedAt + const sortField = + sort?.by === SortBy.SCORE ? SortBy.SCORE : 'highlights.updatedAt' + + const searchBody = { + query: { + nested: { + path: 'highlights', + query: { + bool: { + filter: [ + { + term: { + 'highlights.userId': userId, + }, + }, + ], + should: [ + { + multi_match: { + query: query || '', + fields: ['highlights.quote', 'highlights.annotation'], + operator: 'and', + type: 'cross_fields', + }, + }, + ], + minimum_should_match: query ? 1 : 0, + }, + }, + inner_hits: {}, + }, + }, + sort: [ + { + [sortField]: { + order: sortOrder, + nested: { + path: 'highlights', + }, + }, + }, + ], + from, + size, + _source: ['title', 'slug', 'url', 'createdAt'], + } + + console.log('searching highlights in elastic', JSON.stringify(searchBody)) + + const response = await client.search>({ + index: INDEX_ALIAS, + body: searchBody, + }) + + if (response.body.hits.total.value === 0) { + return [[], 0] + } + + const results: SearchItem[] = [] + response.body.hits.hits.forEach((hit) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + hit.inner_hits.highlights.hits.hits.forEach( + (innerHit: { _source: Highlight }) => { + results.push({ + ...hit._source, + ...innerHit._source, + pageId: hit._id, + }) + } + ) + }) + + return [results, response.body.hits.total.value] + } catch (e) { + console.error('failed to search highlights in elastic', e) + return undefined + } +} + +export const updateHighlight = async ( + highlight: Highlight, + ctx: PageContext +): Promise => { + try { + const { body } = await client.updateByQuery({ + index: INDEX_ALIAS, + body: { + script: { + source: `ctx._source.highlights.removeIf(h -> h.id == params.highlight.id); + ctx._source.highlights.add(params.highlight)`, + lang: 'painless', + params: { + highlight: highlight, + }, + }, + query: { + bool: { + filter: [ + { + term: { + userId: ctx.uid, + }, + }, + { + nested: { + path: 'highlights', + query: { + term: { + 'highlights.id': highlight.id, + }, + }, + }, + }, + ], + }, + }, + }, + refresh: ctx.refresh, + }) + + if (body.updated === 0) return false + + await ctx.pubsub.entityUpdated( + EntityType.HIGHLIGHT, + highlight, + ctx.uid + ) + + return true + } catch (e) { + if ( + e instanceof ResponseError && + e.message === 'document_missing_exception' + ) { + console.log('page has been deleted') + return false + } + console.error('failed to update highlight in elastic', e) + return false + } +} diff --git a/packages/api/src/elastic/index.ts b/packages/api/src/elastic/index.ts index 1108c1619..118132069 100644 --- a/packages/api/src/elastic/index.ts +++ b/packages/api/src/elastic/index.ts @@ -1,35 +1,11 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ import { env } from '../env' import { Client } from '@elastic/elasticsearch' -import { - Label, - PageType, - SortBy, - SortOrder, - SortParams, -} from '../generated/graphql' -import { - InFilter, - LabelFilter, - LabelFilterType, - ReadFilter, -} from '../utils/search' -import { - Page, - PageContext, - ParamSet, - SearchBody, - SearchResponse, -} from './types' import { readFileSync } from 'fs' import { join } from 'path' -import { ResponseError } from '@elastic/elasticsearch/lib/errors' -const INDEX_NAME = 'pages' -const INDEX_ALIAS = 'pages_alias' -const client = new Client({ +export const INDEX_NAME = 'pages' +export const INDEX_ALIAS = 'pages_alias' +export const client = new Client({ node: env.elastic.url, maxRetries: 3, requestTimeout: 50000, @@ -52,484 +28,6 @@ const ingest = async (): Promise => { }) } -const appendQuery = (body: SearchBody, query: string): void => { - body.query.bool.should.push({ - multi_match: { - query, - fields: ['title', 'content', 'author', 'description', 'siteName'], - operator: 'and', - type: 'cross_fields', - }, - }) - body.query.bool.minimum_should_match = 1 -} - -const appendTypeFilter = (body: SearchBody, filter: PageType): void => { - body.query.bool.filter.push({ - term: { - pageType: filter, - }, - }) -} - -const appendReadFilter = (body: SearchBody, filter: ReadFilter): void => { - switch (filter) { - case ReadFilter.UNREAD: - body.query.bool.filter.push({ - range: { - readingProgress: { - gte: 98, - }, - }, - }) - break - case ReadFilter.READ: - body.query.bool.filter.push({ - range: { - readingProgress: { - lt: 98, - }, - }, - }) - } -} - -const appendInFilter = (body: SearchBody, filter: InFilter): void => { - switch (filter) { - case InFilter.ARCHIVE: - body.query.bool.filter.push({ - exists: { - field: 'archivedAt', - }, - }) - break - case InFilter.INBOX: - body.query.bool.must_not.push({ - exists: { - field: 'archivedAt', - }, - }) - } -} - -const appendNotNullField = (body: SearchBody, field: string): void => { - body.query.bool.filter.push({ - exists: { - field, - }, - }) -} - -const appendExcludeLabelFilter = ( - body: SearchBody, - filters: LabelFilter[] -): void => { - body.query.bool.must_not.push({ - nested: { - path: 'labels', - query: filters.map((filter) => { - return { - terms: { - 'labels.name': filter.labels, - }, - } - }), - }, - }) -} - -const appendIncludeLabelFilter = ( - body: SearchBody, - filters: LabelFilter[] -): void => { - body.query.bool.filter.push({ - nested: { - path: 'labels', - query: { - bool: { - filter: filters.map((filter) => { - return { - terms: { - 'labels.name': filter.labels, - }, - } - }), - }, - }, - }, - }) -} - -export const createPage = async ( - page: Page, - ctx: PageContext -): Promise => { - try { - const { body } = await client.index({ - id: page.id || undefined, - index: INDEX_ALIAS, - body: { - ...page, - updatedAt: new Date(), - savedAt: new Date(), - }, - refresh: ctx.refresh, - }) - - await ctx.pubsub.pageCreated(page) - - return body._id as string - } catch (e) { - console.error('failed to create a page in elastic', e) - return undefined - } -} - -export const updatePage = async ( - id: string, - page: Partial, - ctx: PageContext -): Promise => { - try { - const { body } = await client.update({ - index: INDEX_ALIAS, - id, - body: { - doc: { - ...page, - updatedAt: new Date(), - }, - }, - refresh: ctx.refresh, - retry_on_conflict: 3, - }) - - if (body.result !== 'updated') return false - - await ctx.pubsub.pageUpdated({ ...page, id }, ctx.uid) - - return true - } catch (e) { - if ( - e instanceof ResponseError && - e.message === 'document_missing_exception' - ) { - console.info('page has been deleted', id) - return false - } - console.error('failed to update a page in elastic', e) - return false - } -} - -export const addLabelInPage = async ( - id: string, - label: Label, - ctx: PageContext -): Promise => { - try { - const { body } = await client.update({ - index: INDEX_ALIAS, - id, - body: { - script: { - source: `if (ctx._source.labels == null) { - ctx._source.labels = [params.label] - } else if (!ctx._source.labels.any(label -> label.name == params.label.name)) { - ctx._source.labels.add(params.label) - } else { ctx.op = 'none' }`, - lang: 'painless', - params: { - label: label, - }, - }, - }, - refresh: ctx.refresh, - retry_on_conflict: 3, - }) - - return body.result === 'updated' - } catch (e) { - console.error('failed to update a page in elastic', e) - return false - } -} - -export const deletePage = async ( - id: string, - ctx: PageContext -): Promise => { - try { - const { body } = await client.delete({ - index: INDEX_ALIAS, - id, - refresh: ctx.refresh, - }) - - if (body.deleted === 0) return false - - await ctx.pubsub.pageDeleted(id, ctx.uid) - - return true - } catch (e) { - console.error('failed to delete a page in elastic', e) - return false - } -} - -export const deleteLabelInPages = async ( - userId: string, - label: string, - ctx: PageContext -): Promise => { - try { - await client.updateByQuery({ - index: INDEX_ALIAS, - body: { - script: { - source: - 'ctx._source.labels.removeIf(label -> label.name == params.label)', - lang: 'painless', - params: { - label: label, - }, - }, - query: { - bool: { - filter: [ - { - term: { - userId, - }, - }, - { - nested: { - path: 'labels', - query: { - term: { - 'labels.name': label, - }, - }, - }, - }, - ], - }, - }, - }, - refresh: ctx.refresh, - }) - } catch (e) { - console.error('failed to delete a page in elastic', e) - } -} - -export const getPageByParam = async ( - param: Record -): Promise => { - try { - const params = { - query: { - bool: { - filter: Object.keys(param).map((key) => { - return { - term: { - [key]: param[key as K], - }, - } - }), - }, - }, - size: 1, - _source: { - excludes: ['originalHtml'], - }, - } - - const { body } = await client.search({ - index: INDEX_ALIAS, - body: params, - }) - - if (body.hits.total.value === 0) { - return undefined - } - - return { - ...body.hits.hits[0]._source, - id: body.hits.hits[0]._id, - } as Page - } catch (e) { - console.error('failed to search pages in elastic', e) - return undefined - } -} - -export const getPageById = async (id: string): Promise => { - try { - const { body } = await client.get({ - index: INDEX_ALIAS, - id, - }) - - return { - ...body._source, - id: body._id, - } as Page - } catch (e) { - console.error('failed to search pages in elastic', e) - return undefined - } -} - -export const searchPages = async ( - args: { - from?: number - size?: number - sort?: SortParams - query?: string - inFilter: InFilter - readFilter: ReadFilter - typeFilter?: PageType - labelFilters: LabelFilter[] - }, - userId: string, - notNullField: string | null = null -): Promise<[Page[], number] | undefined> => { - try { - const { - from = 0, - size = 10, - sort, - query, - readFilter, - typeFilter, - labelFilters, - inFilter, - } = args - const sortOrder = sort?.order === SortOrder.Ascending ? 'asc' : 'desc' - // default sort by saved_at - const sortField = sort?.by === SortBy.Score ? '_score' : 'savedAt' - const includeLabels = labelFilters.filter( - (filter) => filter.type === LabelFilterType.INCLUDE - ) - const excludeLabels = labelFilters.filter( - (filter) => filter.type === LabelFilterType.EXCLUDE - ) - - const body: SearchBody = { - query: { - bool: { - filter: [ - { - term: { - userId, - }, - }, - ], - should: [], - must_not: [], - }, - }, - sort: [ - { - [sortField]: { - order: sortOrder, - }, - }, - ], - from, - size, - _source: { - excludes: ['originalHtml', 'content'], - }, - } - - // append filters - if (query) { - appendQuery(body, query) - } - if (typeFilter) { - appendTypeFilter(body, typeFilter) - } - if (inFilter !== InFilter.ALL) { - appendInFilter(body, inFilter) - } - if (readFilter !== ReadFilter.ALL) { - appendReadFilter(body, readFilter) - } - if (notNullField) { - appendNotNullField(body, notNullField) - } - if (includeLabels.length > 0) { - appendIncludeLabelFilter(body, includeLabels) - } - if (excludeLabels.length > 0) { - appendExcludeLabelFilter(body, excludeLabels) - } - - console.log('searching pages in elastic', JSON.stringify(body)) - - const response = await client.search, SearchBody>({ - index: INDEX_ALIAS, - body, - }) - - if (response.body.hits.total.value === 0) { - return [[], 0] - } - - return [ - response.body.hits.hits.map((hit: { _source: Page; _id: string }) => ({ - ...hit._source, - content: '', - id: hit._id, - })), - response.body.hits.total.value, - ] - } catch (e) { - console.error('failed to search pages in elastic', e) - return undefined - } -} - -export const countByCreatedAt = async ( - userId: string, - from?: number, - to?: number -): Promise => { - try { - const { body } = await client.count({ - index: INDEX_ALIAS, - body: { - query: { - bool: { - filter: [ - { - term: { - userId, - }, - }, - { - range: { - createdAt: { - gte: from, - lte: to, - }, - }, - }, - ], - }, - }, - }, - }) - - return body.count as number - } catch (e) { - console.error('failed to count pages in elastic', e) - return 0 - } -} - export const initElasticsearch = async (): Promise => { try { const response = await client.info() diff --git a/packages/api/src/elastic/labels.ts b/packages/api/src/elastic/labels.ts new file mode 100644 index 000000000..69af5365e --- /dev/null +++ b/packages/api/src/elastic/labels.ts @@ -0,0 +1,133 @@ +import { Label, PageContext } from './types' +import { client, INDEX_ALIAS } from './index' +import { EntityType } from '../datalayer/pubsub' + +export const addLabelInPage = async ( + pageId: string, + label: Label, + ctx: PageContext +): Promise => { + try { + const { body } = await client.update({ + index: INDEX_ALIAS, + id: pageId, + body: { + script: { + source: `if (ctx._source.labels == null) { + ctx._source.labels = [params.label] + } else if (!ctx._source.labels.any(label -> label.name == params.label.name)) { + ctx._source.labels.add(params.label) + } else { ctx.op = 'none' }`, + lang: 'painless', + params: { + label: label, + }, + }, + }, + refresh: ctx.refresh, + retry_on_conflict: 3, + }) + + if (body.result !== 'updated') return false + + await ctx.pubsub.entityCreated