Merge commit '65e233262d7c7171dceb923c5bc7a8ed41ff0be7' into OMN-SB

This commit is contained in:
gitstart-omnivore 2022-04-14 19:10:32 +00:00
commit 0b48daf28c
216 changed files with 11834 additions and 3027 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<AnyCancellable>()
/// Loads initial set of labels when a edit labels list is displayed
/// - Parameters:
/// - dataService: `DataService` reference
/// - item: Optional `FeedItem` for applying labels to a single item
/// - initiallySelectedLabels: Optional `[FeedItemLabel]` for filtering a list of items
func loadLabels(dataService: DataService, item: FeedItem? = nil, initiallySelectedLabels: [FeedItemLabel]? = nil) {
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 }
}
}

View file

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

View file

@ -63,6 +63,12 @@ struct ProfileView: View {
}
Section {
if FeatureFlag.enableLabels {
NavigationLink(destination: LabelsView()) {
Text("Labels")
}
}
NavigationLink(destination: NewsletterEmailsView()) {
Text("Emails")
}

View file

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

View file

@ -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<PresentationMode>
@StateObject var viewModel = WebReaderViewModel()
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@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("<html></html>", baseURL: nil)
}.onDisappear {
// Clear the shared webview content when exiting
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
}
.navigationBarHidden(true)
}
.navigationBarHidden(true)
}
}
#endif

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,16 @@
import Combine
import Foundation
import Models
public class CacheManager: NSObject, NSCacheDelegate {
public func cache(_: NSCache<AnyObject, AnyObject>, willEvictObject obj: Any) {
// This is just used for debugging
if let content = obj as? CachedPageContent {
print("evicting page from cache", content.slug)
}
}
}
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<NSString, CachedPageContent>()
let pageCacheQueue = DispatchQueue.global(qos: .background)
let highlightsCache = NSCache<AnyObject, CachedPDFHighlights>()
let highlightsCacheQueue = DispatchQueue(label: "app.omnivore.highlights.cache.queue", attributes: .concurrent)
let cacheManager: CacheManager
var subscriptions = Set<AnyCancellable>()
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))
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func createLabelPublisher(
name: String,
color: String,
description: String?
) -> AnyPublisher<FeedItemLabel, BasicError> {
enum MutationResult {
case saved(label: FeedItemLabel)
case error(errorCode: Enums.CreateLabelErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateLabelResult> {
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()
}
}

View file

@ -0,0 +1,53 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func removeLabelPublisher(labelID: String) -> AnyPublisher<Bool, BasicError> {
enum MutationResult {
case success(labelID: String)
case error(errorCode: Enums.DeleteLabelErrorCode)
}
let selection = Selection<MutationResult, Unions.DeleteLabelResult> {
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()
}
}

View file

@ -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<MutationResult, Unions.SetLabelsResult> {
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()
}
}

View file

@ -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<ArticleContent, ServerError> {
enum QueryResult {

View file

@ -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<QueryResult, Unions.LabelsResult> {
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()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
SanitizedString_undefined_undefined: String

73
docker-compose-test.yml Normal file
View file

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

View file

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

View file

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

View file

@ -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=
REMINDER_TASK_HANDLER_URL=
ELASTIC_URL=http://localhost:9200

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Page>, userId: string): Promise<void> => {
entityCreated: <T>(
type: EntityType,
data: T,
userId: string
): Promise<void> => {
return publish(
'pageUpdated',
Buffer.from(JSON.stringify({ ...page, userId }))
'entityCreated',
Buffer.from(JSON.stringify({ type, userId, ...data }))
)
},
pageCreated: (page: Page): Promise<void> => {
return publish('pageCreated', Buffer.from(JSON.stringify(page)))
entityUpdated: <T>(
type: EntityType,
data: T,
userId: string
): Promise<void> => {
return publish(
'entityUpdated',
Buffer.from(JSON.stringify({ type, userId, ...data }))
)
},
pageDeleted: (id: string, userId: string): Promise<void> => {
return publish('pageDeleted', Buffer.from(JSON.stringify({ id, userId })))
entityDeleted: (
type: EntityType,
id: string,
userId: string
): Promise<void> => {
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<void>
pageCreated: (page: Page) => Promise<void>
pageUpdated: (page: Partial<Page>, userId: string) => Promise<void>
pageDeleted: (id: string, userId: string) => Promise<void>
entityCreated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
entityUpdated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
entityDeleted: (type: EntityType, id: string, userId: string) => Promise<void>
reportSubmitted(
submitterId: string | undefined,
itemUrl: string,

View file

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

View file

@ -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<boolean> => {
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<Highlight>(
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<Highlight | undefined> => {
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<boolean> => {
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<SearchResponse<Page>>({
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<boolean> => {
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<Highlight>(
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
}
}

View file

@ -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<void> => {
})
}
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<string | undefined> => {
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<Page>,
ctx: PageContext
): Promise<boolean> => {
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<boolean> => {
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<boolean> => {
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<void> => {
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 <K extends keyof ParamSet>(
param: Record<K, Page[K]>
): Promise<Page | undefined> => {
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<Page | undefined> => {
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<SearchResponse<Page>, 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<number> => {
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<void> => {
try {
const response = await client.info()

View file

@ -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<boolean> => {
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<Label & { pageId: string }>(
EntityType.LABEL,
{ pageId, ...label },
ctx.uid
)
return true
} catch (e) {
console.error('failed to add a label in elastic', e)
return false
}
}
export const updateLabelsInPage = async (
pageId: string,
labels: Label[],
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.update({
index: INDEX_ALIAS,
id: pageId,
body: {
doc: {
labels: labels,
updatedAt: new Date(),
},
},
refresh: ctx.refresh,
retry_on_conflict: 3,
})
if (body.result !== 'updated') return false
for (const label of labels) {
await ctx.pubsub.entityCreated<Label & { pageId: string }>(
EntityType.LABEL,
{ pageId, ...label },
ctx.uid
)
}
return true
} catch (e) {
console.error('failed to update labels in elastic', e)
return false
}
}
export const deleteLabelInPages = async (
userId: string,
label: string,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = 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,
})
if (body.updated === 0) return false
await ctx.pubsub.entityDeleted(EntityType.LABEL, label, ctx.uid)
return true
} catch (e) {
console.error('failed to delete a label in elastic', e)
return false
}
}

View file

@ -0,0 +1,475 @@
import {
Page,
PageContext,
PageType,
ParamSet,
SearchBody,
SearchResponse,
} from './types'
import {
DateRangeFilter,
HasFilter,
InFilter,
LabelFilter,
LabelFilterType,
ReadFilter,
SortBy,
SortOrder,
SortParams,
} from '../utils/search'
import { client, INDEX_ALIAS } from './index'
import { EntityType } from '../datalayer/pubsub'
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 appendHasFilters = (body: SearchBody, filters: HasFilter[]): void => {
filters.forEach((filter) => {
switch (filter) {
case HasFilter.HIGHLIGHTS:
body.query.bool.filter.push({
nested: {
path: 'highlights',
query: {
exists: {
field: 'highlights',
},
},
},
})
break
case HasFilter.SHARED_AT:
body.query.bool.filter.push({
exists: {
field: 'sharedAt',
},
})
break
}
})
}
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,
},
}
}),
},
},
},
})
}
const appendSavedDateFilter = (
body: SearchBody,
filter: DateRangeFilter
): void => {
body.query.bool.filter.push({
range: {
savedAt: {
gt: filter.startDate,
lt: filter.endDate,
},
},
})
}
const appendPublishedDateFilter = (
body: SearchBody,
filter: DateRangeFilter
): void => {
body.query.bool.filter.push({
range: {
publishedAt: {
gt: filter.startDate,
lt: filter.endDate,
},
},
})
}
export const createPage = async (
page: Page,
ctx: PageContext
): Promise<string | undefined> => {
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.entityCreated<Page>(EntityType.PAGE, page, ctx.uid)
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<Page>,
ctx: PageContext
): Promise<boolean> => {
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.entityUpdated<Partial<Page>>(
EntityType.PAGE,
{ ...page, id },
ctx.uid
)
return true
} catch (e) {
console.error('failed to update a page in elastic', e)
return false
}
}
export const deletePage = async (
id: string,
ctx: PageContext
): Promise<boolean> => {
try {
const { body } = await client.delete({
index: INDEX_ALIAS,
id,
refresh: ctx.refresh,
})
if (body.deleted === 0) return false
await ctx.pubsub.entityDeleted(EntityType.PAGE, id, ctx.uid)
return true
} catch (e) {
console.error('failed to delete a page in elastic', e)
return false
}
}
export const getPageByParam = async <K extends keyof ParamSet>(
param: Record<K, Page[K]>
): Promise<Page | undefined> => {
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<SearchResponse<Page>>({
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<Page | undefined> => {
try {
const { body } = await client.get({
index: INDEX_ALIAS,
id,
})
return {
...body._source,
id: body._id as string,
} 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[]
hasFilters?: HasFilter[]
savedDateFilter?: DateRangeFilter
publishedDateFilter?: DateRangeFilter
},
userId: string
): Promise<[Page[], number] | undefined> => {
try {
const {
from = 0,
size = 10,
sort,
query,
readFilter = ReadFilter.ALL,
typeFilter,
labelFilters = [],
inFilter = InFilter.ALL,
hasFilters = [],
savedDateFilter,
publishedDateFilter,
} = args
// default order is descending
const sortOrder = sort?.order || SortOrder.DESCENDING
// default sort by saved_at
const sortField = sort?.by || SortBy.SAVED
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', 'highlights'],
},
}
// 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 (hasFilters.length > 0) {
appendHasFilters(body, hasFilters)
}
if (includeLabels.length > 0) {
appendIncludeLabelFilter(body, includeLabels)
}
if (excludeLabels.length > 0) {
appendExcludeLabelFilter(body, excludeLabels)
}
if (savedDateFilter) {
appendSavedDateFilter(body, savedDateFilter)
}
if (publishedDateFilter) {
appendPublishedDateFilter(body, publishedDateFilter)
}
console.log('searching pages in elastic', JSON.stringify(body))
const response = await client.search<SearchResponse<Page>, 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<number> => {
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
}
}

View file

@ -1,5 +1,4 @@
// Define the type of the body for the Search request
import { Label, PageType } from '../generated/graphql'
import { PickTuple } from '../util'
import { PubsubClient } from '../datalayer/pubsub'
@ -19,6 +18,16 @@ export interface SearchBody {
readingProgress: { gte: number } | { lt: number }
}
}
| {
range: {
savedAt: { gt: Date | undefined } | { lt: Date | undefined }
}
}
| {
range: {
publishedAt: { gt: Date | undefined } | { lt: Date | undefined }
}
}
| {
nested: {
path: 'labels'
@ -33,6 +42,16 @@ export interface SearchBody {
}
}
}
| {
nested: {
path: 'highlights'
query: {
exists: {
field: 'highlights'
}
}
}
}
)[]
should: {
multi_match: {
@ -109,7 +128,7 @@ export interface SearchResponse<T> {
_explanation?: Explanation
fields?: never
highlight?: never
inner_hits?: never
inner_hits?: any
matched_queries?: string[]
sort?: string[]
}>
@ -117,6 +136,38 @@ export interface SearchResponse<T> {
aggregations?: never
}
export enum PageType {
Article = 'ARTICLE',
Book = 'BOOK',
File = 'FILE',
Profile = 'PROFILE',
Unknown = 'UNKNOWN',
Website = 'WEBSITE',
Highlights = 'HIGHLIGHTS',
}
export interface Label {
id: string
name: string
color: string
description?: string
createdAt?: Date
}
export interface Highlight {
id: string
shortId: string
patch: string
quote: string
userId: string
createdAt: Date
prefix?: string | null
suffix?: string | null
annotation?: string | null
sharedAt?: Date | null
updatedAt?: Date | null
}
export interface Page {
id: string
userId: string
@ -143,6 +194,29 @@ export interface Page {
siteName?: string
_id?: string
siteIcon?: string
highlights?: Highlight[]
}
export interface SearchItem {
annotation?: string | null
author?: string | null
createdAt: Date
description?: string | null
id: string
image?: string | null
pageId?: string
pageType: PageType
publishedAt?: Date
quote?: string | null
shortId?: string | null
slug: string
title: string
uploadFileId?: string | null
url: string
archivedAt?: Date | null
readingProgressPercent?: number
readingProgressAnchorIndex?: number
userId: string
}
const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId'] as const

View file

@ -1,16 +1,15 @@
import {
Entity,
BaseEntity,
PrimaryGeneratedColumn,
CreateDateColumn,
OneToOne,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'user_friends' })
export class Follower extends BaseEntity {
export class Follower {
@PrimaryGeneratedColumn('uuid')
id?: string

View file

@ -1,18 +1,17 @@
import {
Entity,
BaseEntity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../user'
@Entity()
export class Group extends BaseEntity {
export class Group {
@PrimaryGeneratedColumn('uuid')
id?: string

View file

@ -1,11 +1,10 @@
import {
Entity,
BaseEntity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../user'
@ -13,7 +12,7 @@ import { Group } from './group'
import { Invite } from './invite'
@Entity()
export class GroupMembership extends BaseEntity {
export class GroupMembership {
@PrimaryGeneratedColumn('uuid')
id?: string

View file

@ -1,19 +1,18 @@
import {
Entity,
BaseEntity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../user'
import { Group } from './group'
@Entity()
export class Invite extends BaseEntity {
export class Invite {
@PrimaryGeneratedColumn('uuid')
id?: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -12,7 +11,7 @@ import { User } from './user'
import { Page } from './page'
@Entity({ name: 'highlight' })
export class Highlight extends BaseEntity {
export class Highlight {
@PrimaryGeneratedColumn('uuid')
id?: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -10,7 +9,7 @@ import {
import { User } from './user'
@Entity({ name: 'labels' })
export class Label extends BaseEntity {
export class Label {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -10,7 +10,6 @@
// shared_with_highlights | boolean | | | false
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -27,7 +26,7 @@ import { Page } from './page'
import { Label } from './label'
@Entity({ name: 'links' })
export class Link extends BaseEntity {
export class Link {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
CreateDateColumn,
Entity,
JoinColumn,
@ -10,7 +9,7 @@ import { Link } from './link'
import { Label } from './label'
@Entity({ name: 'link_labels' })
export class LinkLabel extends BaseEntity {
export class LinkLabel {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -11,7 +10,7 @@ import {
import { User } from './user'
@Entity({ name: 'newsletter_emails' })
export class NewsletterEmail extends BaseEntity {
export class NewsletterEmail {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -8,7 +7,7 @@ import {
} from 'typeorm'
@Entity({ name: 'pages' })
export class Page extends BaseEntity {
export class Page {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,18 +1,17 @@
import {
Entity,
BaseEntity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'user_profile' })
export class Profile extends BaseEntity {
export class Profile {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -11,7 +10,7 @@ import {
import { User } from './user'
@Entity({ name: 'reminders' })
export class Reminder extends BaseEntity {
export class Reminder {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -9,7 +8,7 @@ import {
import { ReportType } from '../../generated/graphql'
@Entity()
export class AbuseReport extends BaseEntity {
export class AbuseReport {
@PrimaryGeneratedColumn('uuid')
id?: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -8,7 +7,7 @@ import {
} from 'typeorm'
@Entity()
export class ContentDisplayReport extends BaseEntity {
export class ContentDisplayReport {
@PrimaryGeneratedColumn('uuid')
id?: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -11,7 +10,7 @@ import {
import { User } from './user'
@Entity({ name: 'upload_files' })
export class UploadFile extends BaseEntity {
export class UploadFile {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -14,7 +13,7 @@ import { Profile } from './profile'
import { Label } from './label'
@Entity()
export class User extends BaseEntity {
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,5 +1,4 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
@ -10,7 +9,7 @@ import {
import { User } from './user'
@Entity({ name: 'user_device_tokens' })
export class UserDeviceToken extends BaseEntity {
export class UserDeviceToken {
@PrimaryGeneratedColumn('uuid')
id!: string

View file

@ -1,4 +1,5 @@
import { EntityManager } from 'typeorm'
import { EntityManager, EntityTarget, Repository } from 'typeorm'
import { AppDataSource } from '../server'
export const setClaims = async (
t: EntityManager,
@ -9,3 +10,7 @@ export const setClaims = async (
.query('SELECT * from omnivore.set_claims($1, $2)', [uid, dbRole])
.then()
}
export const getRepository = <T>(entity: EntityTarget<T>): Repository<T> => {
return AppDataSource.getRepository(entity)
}

View file

@ -32,8 +32,7 @@ export class FollowOmnivoreUser implements EntitySubscriberInterface<Profile> {
await event.manager
.getRepository(Follower)
.create({ user: event.entity.user, followee: omnivoreProfile.user })
.save()
.save({ user: event.entity.user, followee: omnivoreProfile.user })
await event.manager.query(
`insert into omnivore.links (user_id, article_id, article_url, article_hash, slug)

View file

@ -521,6 +521,22 @@ export type FeedArticlesSuccess = {
pageInfo: PageInfo;
};
export type GenerateApiKeyError = {
__typename?: 'GenerateApiKeyError';
errorCodes: Array<GenerateApiKeyErrorCode>;
};
export enum GenerateApiKeyErrorCode {
BadRequest = 'BAD_REQUEST'
}
export type GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess;
export type GenerateApiKeySuccess = {
__typename?: 'GenerateApiKeySuccess';
apiKey: Scalars['String'];
};
export type GetFollowersError = {
__typename?: 'GetFollowersError';
errorCodes: Array<GetFollowersErrorCode>;
@ -599,7 +615,6 @@ export type GoogleSignupSuccess = {
export type Highlight = {
__typename?: 'Highlight';
annotation?: Maybe<Scalars['String']>;
article: Article;
createdAt: Scalars['Date'];
createdByMe: Scalars['Boolean'];
id: Scalars['ID'];
@ -771,6 +786,7 @@ export type Mutation = {
deleteNewsletterEmail: DeleteNewsletterEmailResult;
deleteReaction: DeleteReactionResult;
deleteReminder: DeleteReminderResult;
generateApiKey: GenerateApiKeyResult;
googleLogin: LoginResult;
googleSignup: GoogleSignupResult;
login: LoginResult;
@ -792,6 +808,7 @@ export type Mutation = {
signup: SignupResult;
updateHighlight: UpdateHighlightResult;
updateHighlightReply: UpdateHighlightReplyResult;
updateLabel: UpdateLabelResult;
updateLinkShareInfo: UpdateLinkShareInfoResult;
updateReminder: UpdateReminderResult;
updateSharedComment: UpdateSharedCommentResult;
@ -866,6 +883,11 @@ export type MutationDeleteReminderArgs = {
};
export type MutationGenerateApiKeyArgs = {
scope?: InputMaybe<Scalars['String']>;
};
export type MutationGoogleLoginArgs = {
input: GoogleLoginInput;
};
@ -966,6 +988,11 @@ export type MutationUpdateHighlightReplyArgs = {
};
export type MutationUpdateLabelArgs = {
input: UpdateLabelInput;
};
export type MutationUpdateLinkShareInfoArgs = {
input: UpdateLinkShareInfoInput;
};
@ -1059,6 +1086,7 @@ export enum PageType {
Article = 'ARTICLE',
Book = 'BOOK',
File = 'FILE',
Highlights = 'HIGHLIGHTS',
Profile = 'PROFILE',
Unknown = 'UNKNOWN',
Website = 'WEBSITE'
@ -1092,6 +1120,7 @@ export type Query = {
me?: Maybe<User>;
newsletterEmails: NewsletterEmailsResult;
reminder: ReminderResult;
search: SearchResult;
sharedArticle: SharedArticleResult;
user: UserResult;
users: UsersResult;
@ -1142,6 +1171,13 @@ export type QueryReminderArgs = {
};
export type QuerySearchArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
query?: InputMaybe<Scalars['String']>;
};
export type QuerySharedArticleArgs = {
selectedHighlightId?: InputMaybe<Scalars['String']>;
slug: Scalars['String'];
@ -1295,6 +1331,55 @@ export type SaveUrlInput = {
url: Scalars['String'];
};
export type SearchError = {
__typename?: 'SearchError';
errorCodes: Array<SearchErrorCode>;
};
export enum SearchErrorCode {
Unauthorized = 'UNAUTHORIZED'
}
export type SearchItem = {
__typename?: 'SearchItem';
annotation?: Maybe<Scalars['String']>;
author?: Maybe<Scalars['String']>;
contentReader: ContentReader;
createdAt: Scalars['Date'];
description?: Maybe<Scalars['String']>;
id: Scalars['ID'];
image?: Maybe<Scalars['String']>;
isArchived: Scalars['Boolean'];
labels?: Maybe<Array<Label>>;
originalArticleUrl?: Maybe<Scalars['String']>;
ownedByViewer?: Maybe<Scalars['Boolean']>;
pageId?: Maybe<Scalars['ID']>;
pageType: PageType;
publishedAt?: Maybe<Scalars['Date']>;
quote?: Maybe<Scalars['String']>;
readingProgressAnchorIndex?: Maybe<Scalars['Int']>;
readingProgressPercent?: Maybe<Scalars['Float']>;
shortId?: Maybe<Scalars['String']>;
slug: Scalars['String'];
title: Scalars['String'];
uploadFileId?: Maybe<Scalars['ID']>;
url: Scalars['String'];
};
export type SearchItemEdge = {
__typename?: 'SearchItemEdge';
cursor: Scalars['String'];
node: SearchItem;
};
export type SearchResult = SearchError | SearchSuccess;
export type SearchSuccess = {
__typename?: 'SearchSuccess';
edges: Array<SearchItemEdge>;
pageInfo: PageInfo;
};
export type SetBookmarkArticleError = {
__typename?: 'SetBookmarkArticleError';
errorCodes: Array<SetBookmarkArticleErrorCode>;
@ -1375,7 +1460,7 @@ export enum SetLabelsErrorCode {
export type SetLabelsInput = {
labelIds: Array<Scalars['ID']>;
linkId: Scalars['ID'];
pageId: Scalars['ID'];
};
export type SetLabelsResult = SetLabelsError | SetLabelsSuccess;
@ -1577,6 +1662,32 @@ export type UpdateHighlightSuccess = {
highlight: Highlight;
};
export type UpdateLabelError = {
__typename?: 'UpdateLabelError';
errorCodes: Array<UpdateLabelErrorCode>;
};
export enum UpdateLabelErrorCode {
BadRequest = 'BAD_REQUEST',
Forbidden = 'FORBIDDEN',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type UpdateLabelInput = {
color: Scalars['String'];
description?: InputMaybe<Scalars['String']>;
labelId: Scalars['ID'];
name: Scalars['String'];
};
export type UpdateLabelResult = UpdateLabelError | UpdateLabelSuccess;
export type UpdateLabelSuccess = {
__typename?: 'UpdateLabelSuccess';
label: Label;
};
export type UpdateLinkShareInfoError = {
__typename?: 'UpdateLinkShareInfoError';
errorCodes: Array<UpdateLinkShareInfoErrorCode>;
@ -1957,6 +2068,10 @@ export type ResolversTypes = {
FeedArticlesResult: ResolversTypes['FeedArticlesError'] | ResolversTypes['FeedArticlesSuccess'];
FeedArticlesSuccess: ResolverTypeWrapper<FeedArticlesSuccess>;
Float: ResolverTypeWrapper<Scalars['Float']>;
GenerateApiKeyError: ResolverTypeWrapper<GenerateApiKeyError>;
GenerateApiKeyErrorCode: GenerateApiKeyErrorCode;
GenerateApiKeyResult: ResolversTypes['GenerateApiKeyError'] | ResolversTypes['GenerateApiKeySuccess'];
GenerateApiKeySuccess: ResolverTypeWrapper<GenerateApiKeySuccess>;
GetFollowersError: ResolverTypeWrapper<GetFollowersError>;
GetFollowersErrorCode: GetFollowersErrorCode;
GetFollowersResult: ResolversTypes['GetFollowersError'] | ResolversTypes['GetFollowersSuccess'];
@ -2036,6 +2151,12 @@ export type ResolversTypes = {
SaveResult: ResolversTypes['SaveError'] | ResolversTypes['SaveSuccess'];
SaveSuccess: ResolverTypeWrapper<SaveSuccess>;
SaveUrlInput: SaveUrlInput;
SearchError: ResolverTypeWrapper<SearchError>;
SearchErrorCode: SearchErrorCode;
SearchItem: ResolverTypeWrapper<SearchItem>;
SearchItemEdge: ResolverTypeWrapper<SearchItemEdge>;
SearchResult: ResolversTypes['SearchError'] | ResolversTypes['SearchSuccess'];
SearchSuccess: ResolverTypeWrapper<SearchSuccess>;
SetBookmarkArticleError: ResolverTypeWrapper<SetBookmarkArticleError>;
SetBookmarkArticleErrorCode: SetBookmarkArticleErrorCode;
SetBookmarkArticleInput: SetBookmarkArticleInput;
@ -2095,6 +2216,11 @@ export type ResolversTypes = {
UpdateHighlightReplySuccess: ResolverTypeWrapper<UpdateHighlightReplySuccess>;
UpdateHighlightResult: ResolversTypes['UpdateHighlightError'] | ResolversTypes['UpdateHighlightSuccess'];
UpdateHighlightSuccess: ResolverTypeWrapper<UpdateHighlightSuccess>;
UpdateLabelError: ResolverTypeWrapper<UpdateLabelError>;
UpdateLabelErrorCode: UpdateLabelErrorCode;
UpdateLabelInput: UpdateLabelInput;
UpdateLabelResult: ResolversTypes['UpdateLabelError'] | ResolversTypes['UpdateLabelSuccess'];
UpdateLabelSuccess: ResolverTypeWrapper<UpdateLabelSuccess>;
UpdateLinkShareInfoError: ResolverTypeWrapper<UpdateLinkShareInfoError>;
UpdateLinkShareInfoErrorCode: UpdateLinkShareInfoErrorCode;
UpdateLinkShareInfoInput: UpdateLinkShareInfoInput;
@ -2215,6 +2341,9 @@ export type ResolversParentTypes = {
FeedArticlesResult: ResolversParentTypes['FeedArticlesError'] | ResolversParentTypes['FeedArticlesSuccess'];
FeedArticlesSuccess: FeedArticlesSuccess;
Float: Scalars['Float'];
GenerateApiKeyError: GenerateApiKeyError;
GenerateApiKeyResult: ResolversParentTypes['GenerateApiKeyError'] | ResolversParentTypes['GenerateApiKeySuccess'];
GenerateApiKeySuccess: GenerateApiKeySuccess;
GetFollowersError: GetFollowersError;
GetFollowersResult: ResolversParentTypes['GetFollowersError'] | ResolversParentTypes['GetFollowersSuccess'];
GetFollowersSuccess: GetFollowersSuccess;
@ -2280,6 +2409,11 @@ export type ResolversParentTypes = {
SaveResult: ResolversParentTypes['SaveError'] | ResolversParentTypes['SaveSuccess'];
SaveSuccess: SaveSuccess;
SaveUrlInput: SaveUrlInput;
SearchError: SearchError;
SearchItem: SearchItem;
SearchItemEdge: SearchItemEdge;
SearchResult: ResolversParentTypes['SearchError'] | ResolversParentTypes['SearchSuccess'];
SearchSuccess: SearchSuccess;
SetBookmarkArticleError: SetBookmarkArticleError;
SetBookmarkArticleInput: SetBookmarkArticleInput;
SetBookmarkArticleResult: ResolversParentTypes['SetBookmarkArticleError'] | ResolversParentTypes['SetBookmarkArticleSuccess'];
@ -2326,6 +2460,10 @@ export type ResolversParentTypes = {
UpdateHighlightReplySuccess: UpdateHighlightReplySuccess;
UpdateHighlightResult: ResolversParentTypes['UpdateHighlightError'] | ResolversParentTypes['UpdateHighlightSuccess'];
UpdateHighlightSuccess: UpdateHighlightSuccess;
UpdateLabelError: UpdateLabelError;
UpdateLabelInput: UpdateLabelInput;
UpdateLabelResult: ResolversParentTypes['UpdateLabelError'] | ResolversParentTypes['UpdateLabelSuccess'];
UpdateLabelSuccess: UpdateLabelSuccess;
UpdateLinkShareInfoError: UpdateLinkShareInfoError;
UpdateLinkShareInfoInput: UpdateLinkShareInfoInput;
UpdateLinkShareInfoResult: ResolversParentTypes['UpdateLinkShareInfoError'] | ResolversParentTypes['UpdateLinkShareInfoSuccess'];
@ -2363,6 +2501,7 @@ export type ResolversParentTypes = {
export type SanitizeDirectiveArgs = {
allowedTags?: Maybe<Array<Maybe<Scalars['String']>>>;
maxLength?: Maybe<Scalars['Int']>;
pattern?: Maybe<Scalars['String']>;
};
export type SanitizeDirectiveResolver<Result, Parent, ContextType = ResolverContext, Args = SanitizeDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
@ -2722,6 +2861,20 @@ export type FeedArticlesSuccessResolvers<ContextType = ResolverContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GenerateApiKeyErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GenerateApiKeyError'] = ResolversParentTypes['GenerateApiKeyError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GenerateApiKeyErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GenerateApiKeyResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GenerateApiKeyResult'] = ResolversParentTypes['GenerateApiKeyResult']> = {
__resolveType: TypeResolveFn<'GenerateApiKeyError' | 'GenerateApiKeySuccess', ParentType, ContextType>;
};
export type GenerateApiKeySuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GenerateApiKeySuccess'] = ResolversParentTypes['GenerateApiKeySuccess']> = {
apiKey?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetFollowersErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetFollowersError'] = ResolversParentTypes['GetFollowersError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GetFollowersErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -2780,7 +2933,6 @@ export type GoogleSignupSuccessResolvers<ContextType = ResolverContext, ParentTy
export type HighlightResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Highlight'] = ResolversParentTypes['Highlight']> = {
annotation?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
article?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
createdByMe?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@ -2916,6 +3068,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteReaction?: Resolver<ResolversTypes['DeleteReactionResult'], ParentType, ContextType, RequireFields<MutationDeleteReactionArgs, 'id'>>;
deleteReminder?: Resolver<ResolversTypes['DeleteReminderResult'], ParentType, ContextType, RequireFields<MutationDeleteReminderArgs, 'id'>>;
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, Partial<MutationGenerateApiKeyArgs>>;
googleLogin?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationGoogleLoginArgs, 'input'>>;
googleSignup?: Resolver<ResolversTypes['GoogleSignupResult'], ParentType, ContextType, RequireFields<MutationGoogleSignupArgs, 'input'>>;
login?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationLoginArgs, 'input'>>;
@ -2937,6 +3090,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
signup?: Resolver<ResolversTypes['SignupResult'], ParentType, ContextType, RequireFields<MutationSignupArgs, 'input'>>;
updateHighlight?: Resolver<ResolversTypes['UpdateHighlightResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightArgs, 'input'>>;
updateHighlightReply?: Resolver<ResolversTypes['UpdateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightReplyArgs, 'input'>>;
updateLabel?: Resolver<ResolversTypes['UpdateLabelResult'], ParentType, ContextType, RequireFields<MutationUpdateLabelArgs, 'input'>>;
updateLinkShareInfo?: Resolver<ResolversTypes['UpdateLinkShareInfoResult'], ParentType, ContextType, RequireFields<MutationUpdateLinkShareInfoArgs, 'input'>>;
updateReminder?: Resolver<ResolversTypes['UpdateReminderResult'], ParentType, ContextType, RequireFields<MutationUpdateReminderArgs, 'input'>>;
updateSharedComment?: Resolver<ResolversTypes['UpdateSharedCommentResult'], ParentType, ContextType, RequireFields<MutationUpdateSharedCommentArgs, 'input'>>;
@ -3014,6 +3168,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
me?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType>;
newsletterEmails?: Resolver<ResolversTypes['NewsletterEmailsResult'], ParentType, ContextType>;
reminder?: Resolver<ResolversTypes['ReminderResult'], ParentType, ContextType, RequireFields<QueryReminderArgs, 'linkId'>>;
search?: Resolver<ResolversTypes['SearchResult'], ParentType, ContextType, Partial<QuerySearchArgs>>;
sharedArticle?: Resolver<ResolversTypes['SharedArticleResult'], ParentType, ContextType, RequireFields<QuerySharedArticleArgs, 'slug' | 'username'>>;
user?: Resolver<ResolversTypes['UserResult'], ParentType, ContextType, Partial<QueryUserArgs>>;
users?: Resolver<ResolversTypes['UsersResult'], ParentType, ContextType>;
@ -3094,6 +3249,53 @@ export type SaveSuccessResolvers<ContextType = ResolverContext, ParentType exten
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SearchErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SearchError'] = ResolversParentTypes['SearchError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SearchErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SearchItemResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SearchItem'] = ResolversParentTypes['SearchItem']> = {
annotation?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
contentReader?: Resolver<ResolversTypes['ContentReader'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
image?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
isArchived?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
labels?: Resolver<Maybe<Array<ResolversTypes['Label']>>, ParentType, ContextType>;
originalArticleUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
ownedByViewer?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
pageId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
pageType?: Resolver<ResolversTypes['PageType'], ParentType, ContextType>;
publishedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
quote?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
readingProgressAnchorIndex?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
readingProgressPercent?: Resolver<Maybe<ResolversTypes['Float']>, ParentType, ContextType>;
shortId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
uploadFileId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SearchItemEdgeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SearchItemEdge'] = ResolversParentTypes['SearchItemEdge']> = {
cursor?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
node?: Resolver<ResolversTypes['SearchItem'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SearchResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SearchResult'] = ResolversParentTypes['SearchResult']> = {
__resolveType: TypeResolveFn<'SearchError' | 'SearchSuccess', ParentType, ContextType>;
};
export type SearchSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SearchSuccess'] = ResolversParentTypes['SearchSuccess']> = {
edges?: Resolver<Array<ResolversTypes['SearchItemEdge']>, ParentType, ContextType>;
pageInfo?: Resolver<ResolversTypes['PageInfo'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetBookmarkArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetBookmarkArticleError'] = ResolversParentTypes['SetBookmarkArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetBookmarkArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3257,6 +3459,20 @@ export type UpdateHighlightSuccessResolvers<ContextType = ResolverContext, Paren
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateLabelErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateLabelError'] = ResolversParentTypes['UpdateLabelError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UpdateLabelErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateLabelResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateLabelResult'] = ResolversParentTypes['UpdateLabelResult']> = {
__resolveType: TypeResolveFn<'UpdateLabelError' | 'UpdateLabelSuccess', ParentType, ContextType>;
};
export type UpdateLabelSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateLabelSuccess'] = ResolversParentTypes['UpdateLabelSuccess']> = {
label?: Resolver<ResolversTypes['Label'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateLinkShareInfoErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateLinkShareInfoError'] = ResolversParentTypes['UpdateLinkShareInfoError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UpdateLinkShareInfoErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3465,6 +3681,9 @@ export type Resolvers<ContextType = ResolverContext> = {
FeedArticlesError?: FeedArticlesErrorResolvers<ContextType>;
FeedArticlesResult?: FeedArticlesResultResolvers<ContextType>;
FeedArticlesSuccess?: FeedArticlesSuccessResolvers<ContextType>;
GenerateApiKeyError?: GenerateApiKeyErrorResolvers<ContextType>;
GenerateApiKeyResult?: GenerateApiKeyResultResolvers<ContextType>;
GenerateApiKeySuccess?: GenerateApiKeySuccessResolvers<ContextType>;
GetFollowersError?: GetFollowersErrorResolvers<ContextType>;
GetFollowersResult?: GetFollowersResultResolvers<ContextType>;
GetFollowersSuccess?: GetFollowersSuccessResolvers<ContextType>;
@ -3517,6 +3736,11 @@ export type Resolvers<ContextType = ResolverContext> = {
SaveError?: SaveErrorResolvers<ContextType>;
SaveResult?: SaveResultResolvers<ContextType>;
SaveSuccess?: SaveSuccessResolvers<ContextType>;
SearchError?: SearchErrorResolvers<ContextType>;
SearchItem?: SearchItemResolvers<ContextType>;
SearchItemEdge?: SearchItemEdgeResolvers<ContextType>;
SearchResult?: SearchResultResolvers<ContextType>;
SearchSuccess?: SearchSuccessResolvers<ContextType>;
SetBookmarkArticleError?: SetBookmarkArticleErrorResolvers<ContextType>;
SetBookmarkArticleResult?: SetBookmarkArticleResultResolvers<ContextType>;
SetBookmarkArticleSuccess?: SetBookmarkArticleSuccessResolvers<ContextType>;
@ -3551,6 +3775,9 @@ export type Resolvers<ContextType = ResolverContext> = {
UpdateHighlightReplySuccess?: UpdateHighlightReplySuccessResolvers<ContextType>;
UpdateHighlightResult?: UpdateHighlightResultResolvers<ContextType>;
UpdateHighlightSuccess?: UpdateHighlightSuccessResolvers<ContextType>;
UpdateLabelError?: UpdateLabelErrorResolvers<ContextType>;
UpdateLabelResult?: UpdateLabelResultResolvers<ContextType>;
UpdateLabelSuccess?: UpdateLabelSuccessResolvers<ContextType>;
UpdateLinkShareInfoError?: UpdateLinkShareInfoErrorResolvers<ContextType>;
UpdateLinkShareInfoResult?: UpdateLinkShareInfoResultResolvers<ContextType>;
UpdateLinkShareInfoSuccess?: UpdateLinkShareInfoSuccessResolvers<ContextType>;

View file

@ -1,4 +1,4 @@
directive @sanitize(allowedTags: [String], maxLength: Int) on INPUT_FIELD_DEFINITION
directive @sanitize(allowedTags: [String], maxLength: Int, pattern: String) on INPUT_FIELD_DEFINITION
type ArchiveLinkError {
errorCodes: [ArchiveLinkErrorCode!]!
@ -457,6 +457,20 @@ type FeedArticlesSuccess {
pageInfo: PageInfo!
}
type GenerateApiKeyError {
errorCodes: [GenerateApiKeyErrorCode!]!
}
enum GenerateApiKeyErrorCode {
BAD_REQUEST
}
union GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess
type GenerateApiKeySuccess {
apiKey: String!
}
type GetFollowersError {
errorCodes: [GetFollowersErrorCode!]!
}
@ -526,7 +540,6 @@ type GoogleSignupSuccess {
type Highlight {
annotation: String
article: Article!
createdAt: Date!
createdByMe: Boolean!
id: ID!
@ -684,6 +697,7 @@ type Mutation {
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteReaction(id: ID!): DeleteReactionResult!
deleteReminder(id: ID!): DeleteReminderResult!
generateApiKey(scope: String): GenerateApiKeyResult!
googleLogin(input: GoogleLoginInput!): LoginResult!
googleSignup(input: GoogleSignupInput!): GoogleSignupResult!
login(input: LoginInput!): LoginResult!
@ -705,6 +719,7 @@ type Mutation {
signup(input: SignupInput!): SignupResult!
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult!
updateLabel(input: UpdateLabelInput!): UpdateLabelResult!
updateLinkShareInfo(input: UpdateLinkShareInfoInput!): UpdateLinkShareInfoResult!
updateReminder(input: UpdateReminderInput!): UpdateReminderResult!
updateSharedComment(input: UpdateSharedCommentInput!): UpdateSharedCommentResult!
@ -772,6 +787,7 @@ enum PageType {
ARTICLE
BOOK
FILE
HIGHLIGHTS
PROFILE
UNKNOWN
WEBSITE
@ -803,6 +819,7 @@ type Query {
me: User
newsletterEmails: NewsletterEmailsResult!
reminder(linkId: ID!): ReminderResult!
search(after: String, first: Int, query: String): SearchResult!
sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult!
user(userId: ID, username: String): UserResult!
users: UsersResult!
@ -935,6 +952,51 @@ input SaveUrlInput {
url: String!
}
type SearchError {
errorCodes: [SearchErrorCode!]!
}
enum SearchErrorCode {
UNAUTHORIZED
}
type SearchItem {
annotation: String
author: String
contentReader: ContentReader!
createdAt: Date!
description: String
id: ID!
image: String
isArchived: Boolean!
labels: [Label!]
originalArticleUrl: String
ownedByViewer: Boolean
pageId: ID
pageType: PageType!
publishedAt: Date
quote: String
readingProgressAnchorIndex: Int
readingProgressPercent: Float
shortId: String
slug: String!
title: String!
uploadFileId: ID
url: String!
}
type SearchItemEdge {
cursor: String!
node: SearchItem!
}
union SearchResult = SearchError | SearchSuccess
type SearchSuccess {
edges: [SearchItemEdge!]!
pageInfo: PageInfo!
}
type SetBookmarkArticleError {
errorCodes: [SetBookmarkArticleErrorCode!]!
}
@ -1008,7 +1070,7 @@ enum SetLabelsErrorCode {
input SetLabelsInput {
labelIds: [ID!]!
linkId: ID!
pageId: ID!
}
union SetLabelsResult = SetLabelsError | SetLabelsSuccess
@ -1194,6 +1256,30 @@ type UpdateHighlightSuccess {
highlight: Highlight!
}
type UpdateLabelError {
errorCodes: [UpdateLabelErrorCode!]!
}
enum UpdateLabelErrorCode {
BAD_REQUEST
FORBIDDEN
NOT_FOUND
UNAUTHORIZED
}
input UpdateLabelInput {
color: String!
description: String
labelId: ID!
name: String!
}
union UpdateLabelResult = UpdateLabelError | UpdateLabelSuccess
type UpdateLabelSuccess {
label: Label!
}
type UpdateLinkShareInfoError {
errorCodes: [UpdateLinkShareInfoErrorCode!]!
}

View file

@ -0,0 +1,40 @@
import {
GenerateApiKeyError,
GenerateApiKeyErrorCode,
GenerateApiKeySuccess,
MutationGenerateApiKeyArgs,
} from '../../generated/graphql'
import { generateApiKey } from '../../utils/auth'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { authorized } from '../../utils/helpers'
export const generateApiKeyResolver = authorized<
GenerateApiKeySuccess,
GenerateApiKeyError,
MutationGenerateApiKeyArgs
>((_, { scope }, { claims }) => {
try {
console.log('generateApiKeyResolver', scope)
analytics.track({
userId: claims.uid,
event: 'generate_api_key',
properties: {
scope,
env: env.server.apiEnv,
},
})
const apiKey = generateApiKey({
iat: new Date().getTime(),
scope: scope || 'all',
uid: claims.uid,
})
return { apiKey }
} catch (error) {
console.error(error)
return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] }
}
})

View file

@ -22,10 +22,14 @@ import {
PageType,
QueryArticleArgs,
QueryArticlesArgs,
QuerySearchArgs,
ResolverFn,
SaveArticleReadingProgressError,
SaveArticleReadingProgressErrorCode,
SaveArticleReadingProgressSuccess,
SearchError,
SearchItem,
SearchSuccess,
SetBookmarkArticleError,
SetBookmarkArticleErrorCode,
SetBookmarkArticleSuccess,
@ -67,6 +71,8 @@ import { createPageSaveRequest } from '../../services/create_page_save_request'
import { createIntercomEvent } from '../../utils/intercom'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { Page, SearchItem as SearchItemData } from '../../elastic/types'
import {
createPage,
deletePage,
@ -74,8 +80,8 @@ import {
getPageByParam,
searchPages,
updatePage,
} from '../../elastic'
import { Page } from '../../elastic/types'
} from '../../elastic/pages'
import { searchHighlights } from '../../elastic/highlights'
export type PartialArticle = Omit<
Article,
@ -438,44 +444,45 @@ export const getArticlesResolver = authorized<
ArticlesError,
QueryArticlesArgs
>(async (_obj, params, { claims }) => {
const notNullField = params.sharedOnly ? 'sharedAt' : null
const startCursor = params.after || ''
const first = params.first || 10
// Perform basic sanitization. Right now we just allow alphanumeric, space and quote
// so queries can contain phrases like "human race";
// We can also split out terms like "label:unread".
const searchQuery = parseSearchQuery(params.query || undefined)
analytics.track({
userId: claims.uid,
event: 'search',
event: 'get_articles',
properties: {
env: env.server.apiEnv,
query: searchQuery.query,
inFilter: searchQuery.inFilter,
readFilter: searchQuery.readFilter,
typeFilter: searchQuery.typeFilter,
labelFilters: searchQuery.labelFilters,
sortParams: searchQuery.sortParams,
env: env.server.apiEnv,
hasFilters: searchQuery.hasFilters,
savedDateFilter: searchQuery.savedDateFilter,
publishedDateFilter: searchQuery.publishedDateFilter,
},
})
await createIntercomEvent('search', claims.uid)
await createIntercomEvent('get_articles', claims.uid)
const [pages, totalCount] = (await searchPages(
{
from: Number(startCursor),
size: first + 1, // fetch one more item to get next cursor
sort: searchQuery.sortParams || params.sort || undefined,
sort: searchQuery.sortParams,
query: searchQuery.query,
inFilter: searchQuery.inFilter,
readFilter: searchQuery.readFilter,
typeFilter: searchQuery.typeFilter,
labelFilters: searchQuery.labelFilters,
hasFilters: searchQuery.hasFilters,
savedDateFilter: searchQuery.savedDateFilter,
publishedDateFilter: searchQuery.publishedDateFilter,
},
claims.uid,
notNullField
claims.uid
)) || [[], 0]
const start =
@ -792,3 +799,106 @@ export const getReadingProgressAnchorIndexForArticleResolver: ResolverFn<
return articleReadingProgressAnchorIndex || 0
}
export const searchResolver = authorized<
SearchSuccess,
SearchError,
QuerySearchArgs
>(async (_obj, params, { claims }) => {
const startCursor = params.after || ''
const first = params.first || 10
const searchQuery = parseSearchQuery(params.query || undefined)
analytics.track({
userId: claims.uid,
event: 'search',
properties: {
query: searchQuery.query,
inFilter: searchQuery.inFilter,
readFilter: searchQuery.readFilter,
typeFilter: searchQuery.typeFilter,
labelFilters: searchQuery.labelFilters,
sortParams: searchQuery.sortParams,
hasFilters: searchQuery.hasFilters,
savedDateFilter: searchQuery.savedDateFilter,
publishedDateFilter: searchQuery.publishedDateFilter,
env: env.server.apiEnv,
},
})
await createIntercomEvent('search', claims.uid)
let results: (SearchItemData | Page)[]
let totalCount: number
const searchType = searchQuery.typeFilter
// search highlights if type:highlights
if (searchType === PageType.Highlights) {
;[results, totalCount] = (await searchHighlights(
{
from: Number(startCursor),
size: first + 1, // fetch one more item to get next cursor
sort: searchQuery.sortParams,
query: searchQuery.query,
},
claims.uid
)) || [[], 0]
} else {
// otherwise, search pages
;[results, totalCount] = (await searchPages(
{
from: Number(startCursor),
size: first + 1, // fetch one more item to get next cursor
sort: searchQuery.sortParams,
query: searchQuery.query,
inFilter: searchQuery.inFilter,
readFilter: searchQuery.readFilter,
typeFilter: searchQuery.typeFilter,
labelFilters: searchQuery.labelFilters,
hasFilters: searchQuery.hasFilters,
savedDateFilter: searchQuery.savedDateFilter,
publishedDateFilter: searchQuery.publishedDateFilter,
},
claims.uid
)) || [[], 0]
}
const start =
startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0
const hasNextPage = results.length > first
const endCursor = String(start + results.length - (hasNextPage ? 1 : 0))
if (hasNextPage) {
// remove an extra if exists
results.pop()
}
const edges = results.map((r) => {
return {
node: {
...r,
image: r.image && createImageProxyUrl(r.image, 88, 88),
isArchived: !!r.archivedAt,
contentReader:
r.pageType === PageType.File ? ContentReader.Pdf : ContentReader.Web,
originalArticleUrl: r.url,
publishedAt: validatedDate(r.publishedAt),
ownedByViewer: r.userId === claims.uid,
pageType: r.pageType || PageType.Highlights,
} as SearchItem,
cursor: endCursor,
}
})
return {
edges,
pageInfo: {
hasPreviousPage: false,
startCursor,
hasNextPage: hasNextPage,
endCursor,
totalCount,
},
}
})

View file

@ -16,6 +16,7 @@ import {
LinkShareInfo,
PageType,
Reaction,
SearchItem,
User,
} from './../generated/graphql'
@ -54,6 +55,7 @@ import {
saveFileResolver,
savePageResolver,
saveUrlResolver,
searchResolver,
setBookmarkArticleResolver,
setDeviceTokenResolver,
setFollowResolver,
@ -71,13 +73,15 @@ import {
updateUserResolver,
uploadFileRequestResolver,
validateUsernameResolver,
updateLabelResolver,
} from './index'
import { getShareInfoForArticle } from '../datalayer/links/share_info'
import {
generateDownloadSignedUrl,
generateUploadFilePathName,
} from '../utils/uploads'
import { getPageById, getPageByParam } from '../elastic'
import { getPageByParam } from '../elastic/pages'
import { generateApiKeyResolver } from './api_key'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
@ -132,10 +136,12 @@ export const functionResolvers = {
deleteReminder: deleteReminderResolver,
setDeviceToken: setDeviceTokenResolver,
createLabel: createLabelResolver,
updateLabel: updateLabelResolver,
deleteLabel: deleteLabelResolver,
login: loginResolver,
signup: signupResolver,
setLabels: setLabelsResolver,
generateApiKey: generateApiKeyResolver,
},
Query: {
me: getMeUserResolver,
@ -153,6 +159,7 @@ export const functionResolvers = {
newsletterEmails: newsletterEmailsResolver,
reminder: reminderResolver,
labels: labelsResolver,
search: searchResolver,
},
User: {
async sharedArticles(
@ -390,32 +397,33 @@ export const functionResolvers = {
: ContentReader.Web
},
async highlights(
article: { id: string; userId?: string },
article: { id: string; userId?: string; highlights?: Highlight[] },
_: { input: ArticleHighlightsInput },
ctx: WithDataSourcesContext
) {
const includeFriends = false
// TODO: this is a temporary solution until we figure out how collaborative approach would look like
// article has userId only if it's returned by getSharedArticle resolver
if (article.userId) {
const result = await ctx.models.highlight.getForUserArticle(
article.userId,
article.id
)
return result
}
const friendsIds =
ctx.claims?.uid && includeFriends
? await ctx.models.userFriends.getFriends(ctx.claims?.uid)
: []
// FIXME: Move this filtering logic to the datalayer
return (await ctx.models.highlight.batchGet(article.id)).filter((h) =>
[...(includeFriends ? friendsIds : []), ctx.claims?.uid || ''].some(
(u) => u === h.userId
)
)
// const includeFriends = false
// // TODO: this is a temporary solution until we figure out how collaborative approach would look like
// // article has userId only if it's returned by getSharedArticle resolver
// if (article.userId) {
// const result = await ctx.models.highlight.getForUserArticle(
// article.userId,
// article.id
// )
// return result
// }
//
// const friendsIds =
// ctx.claims?.uid && includeFriends
// ? await ctx.models.userFriends.getFriends(ctx.claims?.uid)
// : []
//
// // FIXME: Move this filtering logic to the datalayer
// return (await ctx.models.highlight.batchGet(article.id)).filter((h) =>
// [...(includeFriends ? friendsIds : []), ctx.claims?.uid || ''].some(
// (u) => u === h.userId
// )
// )
return article.highlights || []
},
async shareInfo(
article: { id: string; sharedBy?: User; shareInfo?: LinkShareInfo },
@ -443,9 +451,6 @@ export const functionResolvers = {
},
},
Highlight: {
async article(highlight: { articleId: string }, __: unknown) {
return getPageById(highlight.articleId)
},
async user(
highlight: { userId: string },
__: unknown,
@ -480,6 +485,19 @@ export const functionResolvers = {
return userDataToUser(await ctx.models.user.get(reaction.userId))
},
},
SearchItem: {
async url(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) {
if (item.pageType == PageType.File && ctx.claims && item.uploadFileId) {
const upload = await ctx.models.uploadFile.get(item.uploadFileId)
if (!upload || !upload.fileName) {
return undefined
}
const filePath = generateUploadFilePathName(upload.id, upload.fileName)
return generateDownloadSignedUrl(filePath)
}
return item.url
},
},
...resultResolveTypeResolver('Login'),
...resultResolveTypeResolver('LogOut'),
...resultResolveTypeResolver('GoogleSignup'),
@ -527,4 +545,6 @@ export const functionResolvers = {
...resultResolveTypeResolver('Login'),
...resultResolveTypeResolver('Signup'),
...resultResolveTypeResolver('SetLabels'),
...resultResolveTypeResolver('GenerateApiKey'),
...resultResolveTypeResolver('Search'),
}

View file

@ -3,7 +3,6 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { authorized } from '../../utils/helpers'
import {
Article,
CreateHighlightError,
CreateHighlightErrorCode,
CreateHighlightSuccess,
@ -27,15 +26,20 @@ import {
UpdateHighlightSuccess,
User,
} from '../../generated/graphql'
import { HighlightData } from '../../datalayer/highlight/model'
import { env } from '../../env'
import { analytics } from '../../utils/analytics'
import { getPageById } from '../../elastic'
import { Highlight as HighlightData } from '../../elastic/types'
import { getPageById, updatePage } from '../../elastic/pages'
import {
addHighlightToPage,
deleteHighlight,
getHighlightById,
updateHighlight,
} from '../../elastic/highlights'
const highlightDataToHighlight = (highlight: HighlightData): Highlight => ({
...highlight,
user: highlight.userId as unknown as User,
article: highlight.articleId as unknown as Article,
updatedAt: highlight.updatedAt || highlight.createdAt,
replies: [],
reactions: [],
@ -46,11 +50,10 @@ export const createHighlightResolver = authorized<
CreateHighlightSuccess,
CreateHighlightError,
MutationCreateHighlightArgs
>(async (_, { input }, { models, claims, log }) => {
const { articleId } = input
const article = await getPageById(articleId)
if (!article) {
>(async (_, { input }, { claims, log, pubsub }) => {
const { articleId: pageId } = input
const page = await getPageById(pageId)
if (!page) {
return {
errorCodes: [CreateHighlightErrorCode.NotFound],
}
@ -60,7 +63,7 @@ export const createHighlightResolver = authorized<
userId: claims.uid,
event: 'highlight_created',
properties: {
articleId: article.id,
pageId,
env: env.server.apiEnv,
},
})
@ -72,12 +75,23 @@ export const createHighlightResolver = authorized<
}
try {
const highlight = await models.highlight.create({
...input,
articleId: undefined,
const highlight: HighlightData = {
updatedAt: new Date(),
createdAt: new Date(),
userId: claims.uid,
elasticPageId: article.id,
})
...input,
}
if (
!(await addHighlightToPage(pageId, highlight, {
pubsub,
uid: claims.uid,
}))
) {
return {
errorCodes: [CreateHighlightErrorCode.NotFound],
}
}
log.info('Creating a new highlight', {
highlight,
@ -101,22 +115,27 @@ export const mergeHighlightResolver = authorized<
MergeHighlightSuccess,
MergeHighlightError,
MutationMergeHighlightArgs
>(async (_, { input }, { authTrx, models, claims, log }) => {
const { articleId } = input
>(async (_, { input }, { claims, log, pubsub }) => {
const { articleId: pageId } = input
const { overlapHighlightIdList, ...newHighlightInput } = input
const articleHighlights = await models.highlight.batchGet(articleId)
if (!articleHighlights.length) {
const page = await getPageById(pageId)
if (!page || !page.highlights) {
return {
errorCodes: [MergeHighlightErrorCode.NotFound],
}
}
const articleHighlights = page.highlights
/* Compute merged annotation form the order of highlights appearing on page */
const overlapAnnotations: { [id: string]: string } = {}
articleHighlights.forEach((highlight) => {
if (overlapHighlightIdList.includes(highlight.id) && highlight.annotation) {
overlapAnnotations[highlight.id] = highlight.annotation
articleHighlights.forEach((highlight, index) => {
if (overlapHighlightIdList.includes(highlight.id)) {
articleHighlights.splice(index, 1)
if (highlight.annotation) {
overlapAnnotations[highlight.id] = highlight.annotation
}
}
})
const mergedAnnotation: string[] = []
@ -127,17 +146,20 @@ export const mergeHighlightResolver = authorized<
})
try {
const highlight = await authTrx(async (tx) => {
await models.highlight.deleteMany(overlapHighlightIdList, tx)
return await models.highlight.create({
...newHighlightInput,
articleId: undefined,
annotation: mergedAnnotation ? mergedAnnotation.join('\n') : null,
userId: claims.uid,
elasticPageId: newHighlightInput.articleId,
})
})
if (!highlight) {
const highlight: HighlightData = {
...newHighlightInput,
updatedAt: new Date(),
createdAt: new Date(),
userId: claims.uid,
annotation: mergedAnnotation ? mergedAnnotation.join('\n') : null,
}
const merged = await updatePage(
pageId,
{ highlights: articleHighlights.concat(highlight) },
{ pubsub, uid: claims.uid }
)
if (!merged) {
throw new Error('Failed to create merged highlight')
}
@ -147,7 +169,7 @@ export const mergeHighlightResolver = authorized<
source: 'resolver',
resolver: 'mergeHighlightResolver',
uid: claims.uid,
articleId: articleId,
pageId,
},
})
@ -175,9 +197,9 @@ export const updateHighlightResolver = authorized<
UpdateHighlightSuccess,
UpdateHighlightError,
MutationUpdateHighlightArgs
>(async (_, { input }, { authTrx, models, claims, log }) => {
>(async (_, { input }, { pubsub, claims, log }) => {
const { highlightId } = input
const highlight = await models.highlight.get(highlightId)
const highlight = await getHighlightById(highlightId)
if (!highlight?.id) {
return {
@ -197,16 +219,11 @@ export const updateHighlightResolver = authorized<
}
}
const updatedHighlight = await authTrx((tx) =>
models.highlight.update(
highlightId,
{
annotation: input.annotation,
sharedAt: input.sharedAt,
},
tx
)
)
const updatedHighlight: HighlightData = {
...highlight,
annotation: input.annotation,
updatedAt: new Date(),
}
log.info('Updating a highlight', {
updatedHighlight,
@ -217,6 +234,17 @@ export const updateHighlightResolver = authorized<
},
})
const updated = await updateHighlight(updatedHighlight, {
pubsub,
uid: claims.uid,
})
if (!updated) {
return {
errorCodes: [UpdateHighlightErrorCode.NotFound],
}
}
return { highlight: highlightDataToHighlight(updatedHighlight) }
})
@ -224,8 +252,8 @@ export const deleteHighlightResolver = authorized<
DeleteHighlightSuccess,
DeleteHighlightError,
MutationDeleteHighlightArgs
>(async (_, { highlightId }, { authTrx, models, claims, log }) => {
const highlight = await models.highlight.get(highlightId)
>(async (_, { highlightId }, { claims, log, pubsub }) => {
const highlight = await getHighlightById(highlightId)
if (!highlight?.id) {
return {
@ -239,18 +267,19 @@ export const deleteHighlightResolver = authorized<
}
}
const deletedHighlight = await authTrx((tx) =>
models.highlight.delete(highlightId, tx)
)
const deleted = await deleteHighlight(highlightId, {
pubsub,
uid: claims.uid,
})
if ('error' in deletedHighlight) {
if (!deleted) {
return {
errorCodes: [DeleteHighlightErrorCode.NotFound],
}
}
log.info('Deleting a highlight', {
deletedHighlight,
highlight,
labels: {
source: 'resolver',
resolver: 'deleteHighlightResolver',
@ -258,15 +287,15 @@ export const deleteHighlightResolver = authorized<
},
})
return { highlight: highlightDataToHighlight(deletedHighlight) }
return { highlight: highlightDataToHighlight(highlight) }
})
export const setShareHighlightResolver = authorized<
SetShareHighlightSuccess,
SetShareHighlightError,
MutationSetShareHighlightArgs
>(async (_, { input: { id, share } }, { authTrx, models, claims, log }) => {
const highlight = await models.highlight.get(id)
>(async (_, { input: { id, share } }, { pubsub, claims, log }) => {
const highlight = await getHighlightById(id)
if (!highlight?.id) {
return {
@ -287,16 +316,22 @@ export const setShareHighlightResolver = authorized<
labels: {
source: 'resolver',
resolver: 'setShareHighlightResolver',
articleId: highlight.articleId,
userId: highlight.userId,
},
})
const updatedHighlight = await authTrx((tx) =>
models.highlight.update(id, { sharedAt }, tx)
)
const updatedHighlight: HighlightData = {
...highlight,
sharedAt,
updatedAt: new Date(),
}
if (!updatedHighlight || 'error' in updatedHighlight) {
const updated = await updateHighlight(updatedHighlight, {
pubsub,
uid: claims.uid,
})
if (!updated) {
return {
errorCodes: [SetShareHighlightErrorCode.NotFound],
}

View file

@ -12,18 +12,24 @@ import {
MutationCreateLabelArgs,
MutationDeleteLabelArgs,
MutationSetLabelsArgs,
MutationUpdateLabelArgs,
SetLabelsError,
SetLabelsErrorCode,
SetLabelsSuccess,
UpdateLabelError,
UpdateLabelErrorCode,
UpdateLabelSuccess,
} from '../../generated/graphql'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { User } from '../../entity/user'
import { Label } from '../../entity/label'
import { getManager, getRepository, ILike } from 'typeorm'
import { setClaims } from '../../entity/utils'
import { deleteLabelInPages, getPageById, updatePage } from '../../elastic'
import { ILike, In } from 'typeorm'
import { getRepository, setClaims } from '../../entity/utils'
import { createPubSubClient } from '../../datalayer/pubsub'
import { AppDataSource } from '../../server'
import { getPageById } from '../../elastic/pages'
import { deleteLabelInPages, updateLabelsInPage } from '../../elastic/labels'
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
async (_obj, _params, { claims: { uid }, log }) => {
@ -38,8 +44,14 @@ export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
})
try {
const user = await User.findOne(uid, {
const user = await getRepository(User).findOne({
where: { id: uid },
relations: ['labels'],
order: {
labels: {
createdAt: 'DESC',
},
},
})
if (!user) {
return {
@ -69,7 +81,7 @@ export const createLabelResolver = authorized<
const { name, color, description } = input
try {
const user = await getRepository(User).findOne(uid)
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [CreateLabelErrorCode.Unauthorized],
@ -77,11 +89,9 @@ export const createLabelResolver = authorized<
}
// Check if label already exists ignoring case of name
const existingLabel = await getRepository(Label).findOne({
where: {
user,
name: ILike(name),
},
const existingLabel = await getRepository(Label).findOneBy({
user: { id: user.id },
name: ILike(name),
})
if (existingLabel) {
return {
@ -89,18 +99,16 @@ export const createLabelResolver = authorized<
}
}
const label = await getRepository(Label)
.create({
user,
name,
color,
description: description || '',
})
.save()
const label = await getRepository(Label).save({
user,
name,
color,
description: description || '',
})
analytics.track({
userId: uid,
event: 'createLabel',
event: 'label_created',
properties: {
name,
color,
@ -128,14 +136,15 @@ export const deleteLabelResolver = authorized<
log.info('deleteLabelResolver')
try {
const user = await getRepository(User).findOne(uid)
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [DeleteLabelErrorCode.Unauthorized],
}
}
const label = await getRepository(Label).findOne(labelId, {
const label = await getRepository(Label).findOne({
where: { id: labelId },
relations: ['user'],
})
if (!label) {
@ -150,7 +159,7 @@ export const deleteLabelResolver = authorized<
}
}
const result = await getManager().transaction(async (t) => {
const result = await AppDataSource.transaction(async (t) => {
await setClaims(t, uid)
return t.getRepository(Label).delete(labelId)
})
@ -169,7 +178,7 @@ export const deleteLabelResolver = authorized<
analytics.track({
userId: uid,
event: 'deleteLabel',
event: 'label_deleted',
properties: {
labelId,
env: env.server.apiEnv,
@ -194,10 +203,10 @@ export const setLabelsResolver = authorized<
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
log.info('setLabelsResolver')
const { linkId: pageId, labelIds } = input
const { pageId, labelIds } = input
try {
const user = await getRepository(User).findOne(uid)
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
@ -211,10 +220,8 @@ export const setLabelsResolver = authorized<
}
}
const labels = await getRepository(Label).findByIds(labelIds, {
where: {
user,
},
const labels = await getRepository(Label).find({
where: { id: In(labelIds), user: { id: user.id } },
relations: ['user'],
})
if (labels.length !== labelIds.length) {
@ -224,17 +231,19 @@ export const setLabelsResolver = authorized<
}
// update labels in the page
await updatePage(
pageId,
{
labels,
},
{ pubsub, uid }
)
const updated = await updateLabelsInPage(pageId, labels, {
pubsub,
uid,
})
if (!updated) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
analytics.track({
userId: uid,
event: 'setLabels',
event: 'labels_set',
properties: {
pageId,
labelIds,
@ -252,3 +261,67 @@ export const setLabelsResolver = authorized<
}
}
})
export const updateLabelResolver = authorized<
UpdateLabelSuccess,
UpdateLabelError,
MutationUpdateLabelArgs
>(async (_, { input }, { claims: { uid }, log }) => {
log.info('updateLabelResolver')
try {
const { name, color, description, labelId } = input
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [UpdateLabelErrorCode.Unauthorized],
}
}
const label = await getRepository(Label).findOne({
where: { id: labelId },
relations: ['user'],
})
if (!label) {
return {
errorCodes: [UpdateLabelErrorCode.NotFound],
}
}
const result = await AppDataSource.transaction(async (t) => {
await setClaims(t, uid)
return await t.getRepository(Label).update(
{ id: labelId },
{
name: name,
description: description || undefined,
color: color,
}
)
})
log.info('Updating a label', {
result,
labels: {
source: 'resolver',
resolver: 'updateLabelResolver',
},
})
if (!result) {
log.info('failed to update')
return {
errorCodes: [UpdateLabelErrorCode.BadRequest],
}
}
log.info('updated successfully')
return { label: label }
} catch (error) {
log.error('error updating label', error)
return {
errorCodes: [UpdateLabelErrorCode.BadRequest],
}
}
})

View file

@ -14,7 +14,7 @@ import {
import { authorized } from '../../utils/helpers'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { updatePage } from '../../elastic'
import { updatePage } from '../../elastic/pages'
export const updateLinkShareInfoResolver = authorized<
UpdateLinkShareInfoSuccess,

View file

@ -1,15 +1,15 @@
import { authorized } from '../../utils/helpers'
import {
CreateNewsletterEmailSuccess,
CreateNewsletterEmailError,
CreateNewsletterEmailErrorCode,
NewsletterEmailsSuccess,
NewsletterEmailsError,
NewsletterEmailsErrorCode,
CreateNewsletterEmailSuccess,
DeleteNewsletterEmailError,
DeleteNewsletterEmailErrorCode,
DeleteNewsletterEmailSuccess,
DeleteNewsletterEmailError,
MutationDeleteNewsletterEmailArgs,
NewsletterEmailsError,
NewsletterEmailsErrorCode,
NewsletterEmailsSuccess,
} from '../../generated/graphql'
import {
createNewsletterEmail,
@ -19,6 +19,8 @@ import {
import { NewsletterEmail } from '../../entity/newsletter_email'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { AppDataSource } from '../../server'
import { User } from '../../entity/user'
export const createNewsletterEmailResolver = authorized<
CreateNewsletterEmailSuccess,
@ -55,7 +57,16 @@ export const newsletterEmailsResolver = authorized<
console.log('newsletterEmailsResolver')
try {
const newsletterEmails = await getNewsletterEmails(claims.uid)
const user = await AppDataSource.getRepository(User).findOneBy({
id: claims.uid,
})
if (!user) {
return Promise.reject({
errorCode: NewsletterEmailsErrorCode.Unauthorized,
})
}
const newsletterEmails = await getNewsletterEmails(user.id)
return {
newsletterEmails: newsletterEmails,
@ -84,10 +95,14 @@ export const deleteNewsletterEmailResolver = authorized<
})
try {
const newsletterEmail = await NewsletterEmail.findOne(
args.newsletterEmailId,
{ relations: ['user'] }
)
const newsletterEmail = await AppDataSource.getRepository(
NewsletterEmail
).findOne({
where: {
id: args.newsletterEmailId,
},
relations: ['user'],
})
if (!newsletterEmail) {
return {

View file

@ -20,6 +20,7 @@ export interface Claims {
uid: string
iat: number
userRole?: string
scope?: string // scope is used for api key like page:search
}
export type ClaimsToSet = {

View file

@ -6,8 +6,8 @@ import {
createPubSubClient,
readPushSubscription,
} from '../../datalayer/pubsub'
import { getPageByParam, updatePage } from '../../elastic'
import { Page } from '../../elastic/types'
import { getPageByParam, updatePage } from '../../elastic/pages'
interface UpdateContentMessage {
fileId: string

View file

@ -4,6 +4,7 @@ import { sendEmail } from '../../utils/sendEmail'
import { analytics } from '../../utils/analytics'
import { getNewsletterEmail } from '../../services/newsletters'
import { env } from '../../env'
import { v4 as uuid } from 'uuid'
import { findNewsletterUrl, isProbablyNewsletter } from '../../utils/parser'
import { saveNewsletterEmail } from '../../services/save_newsletter_email'
@ -59,7 +60,7 @@ export function emailsServiceRouter() {
author: data.from,
url:
(await findNewsletterUrl(data.html)) ||
'https://omnivore.app/no_url',
'https://omnivore.app/no_url?q' + uuid(),
})
res.status(200).send('Newsletter')
return

View file

@ -14,9 +14,9 @@ import { analytics } from '../../utils/analytics'
import { getNewsletterEmail } from '../../services/newsletters'
import { setClaims } from '../../datalayer/helpers'
import { generateSlug } from '../../utils/helpers'
import { createPage } from '../../elastic'
import { createPubSubClient } from '../../datalayer/pubsub'
import { Page } from '../../elastic/types'
import { createPage } from '../../elastic/pages'
export function pdfAttachmentsRouter() {
const router = express.Router()
@ -44,7 +44,7 @@ export function pdfAttachmentsRouter() {
analytics.track({
userId: user.id,
event: 'pdf-attachment-upload',
event: 'pdf_attachment_upload',
properties: {
env: env.server.apiEnv,
},
@ -107,7 +107,7 @@ export function pdfAttachmentsRouter() {
analytics.track({
userId: user.id,
event: 'pdf-attachment-create-article',
event: 'pdf_attachment_create_article',
properties: {
env: env.server.apiEnv,
},

View file

@ -6,14 +6,13 @@ import { readPushSubscription } from '../../datalayer/pubsub'
import { generateUploadSignedUrl, uploadToSignedUrl } from '../../utils/uploads'
import { v4 as uuidv4 } from 'uuid'
import { env } from '../../env'
import { Page } from '../../elastic/types'
import { DateTime } from 'luxon'
export function pageServiceRouter() {
export function uploadServiceRouter() {
const router = express.Router()
router.post('/upload/:folder', async (req, res) => {
console.log('upload page data req', req.params.folder)
router.post('/:folder', async (req, res) => {
console.log('upload data to folder', req.params.folder)
const { message: msgStr, expired } = readPushSubscription(req)
if (!msgStr) {
@ -28,9 +27,9 @@ export function pageServiceRouter() {
}
try {
const data: Partial<Page> = JSON.parse(msgStr)
if (!data.userId) {
console.log('No userId found in message')
const data: { userId: string; type: string } = JSON.parse(msgStr)
if (!data.userId || !data.type) {
console.log('No userId or type found in message')
res.status(400).send('Bad Request')
return
}
@ -41,9 +40,9 @@ export function pageServiceRouter() {
console.log('generate upload url')
const uploadUrl = await generateUploadSignedUrl(
`${req.params.folder}/${data.userId}/${DateTime.now().toFormat(
'yyyy-LL-dd'
)}/${uuidv4()}.json`,
`${req.params.folder}/${data.type}/${
data.userId
}/${DateTime.now().toFormat('yyyy-LL-dd')}/${uuidv4()}.json`,
contentType,
bucketName
)

View file

@ -8,10 +8,15 @@ export class SanitizedString extends GraphQLScalarType {
constructor(
type: GraphQLScalarType,
allowedTags?: string[],
maxLength?: number
maxLength?: number,
pattern?: string
) {
super({
name: `SanitizedString_${allowedTags}_${maxLength}`,
// Names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ as per graphql-js
name: `SanitizedString_${allowedTags}_${maxLength}_${pattern}`.replace(
/\W/g,
''
),
description: 'Source string that was sanitized',
serialize(value: string) {
@ -25,6 +30,9 @@ export class SanitizedString extends GraphQLScalarType {
`Specified value cannot be longer than ${maxLength} characters`
)
}
if (pattern && !new RegExp(pattern).test(value)) {
throw new Error(`Specified value does not match pattern`)
}
return sanitize(value, { allowedTags: allowedTags || [] })
},
@ -36,6 +44,9 @@ export class SanitizedString extends GraphQLScalarType {
`Specified value cannot be longer than ${maxLength} characters`
)
}
if (pattern && !new RegExp(pattern).test(value)) {
throw new Error(`Specified value does not match pattern`)
}
return sanitize(value, { allowedTags: allowedTags || [] })
},
})

View file

@ -8,6 +8,7 @@ const schema = gql`
directive @sanitize(
allowedTags: [String]
maxLength: Int
pattern: String
) on INPUT_FIELD_DEFINITION
enum SortOrder {
@ -286,6 +287,7 @@ const schema = gql`
FILE
PROFILE
WEBSITE
HIGHLIGHTS
UNKNOWN
}
@ -569,7 +571,6 @@ const schema = gql`
# used for simplified url format
shortId: String!
user: User!
article: Article!
quote: String!
# piece of content before the quote
prefix: String
@ -1272,9 +1273,9 @@ const schema = gql`
union LabelsResult = LabelsSuccess | LabelsError
input CreateLabelInput {
name: String!
color: String!
description: String
name: String! @sanitize(maxLength: 64)
color: String! @sanitize(pattern: "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")
description: String @sanitize(maxLength: 100)
}
type CreateLabelSuccess {
@ -1310,6 +1311,30 @@ const schema = gql`
union DeleteLabelResult = DeleteLabelSuccess | DeleteLabelError
input UpdateLabelInput {
labelId: ID!
color: String!
description: String
name: String!
}
type UpdateLabelSuccess {
label: Label!
}
enum UpdateLabelErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
FORBIDDEN
}
type UpdateLabelError {
errorCodes: [UpdateLabelErrorCode!]!
}
union UpdateLabelResult = UpdateLabelSuccess | UpdateLabelError
input LoginInput {
password: String!
email: String!
@ -1335,7 +1360,7 @@ const schema = gql`
union SignupResult = SignupSuccess | SignupError
input SetLabelsInput {
linkId: ID!
pageId: ID!
labelIds: [ID!]!
}
@ -1355,6 +1380,70 @@ const schema = gql`
NOT_FOUND
}
union GenerateApiKeyResult = GenerateApiKeySuccess | GenerateApiKeyError
type GenerateApiKeySuccess {
apiKey: String!
}
type GenerateApiKeyError {
errorCodes: [GenerateApiKeyErrorCode!]!
}
enum GenerateApiKeyErrorCode {
BAD_REQUEST
}
# Query: search
union SearchResult = SearchSuccess | SearchError
type SearchItem {
# used for pages
id: ID!
title: String!
slug: String!
# for uploaded file articles (PDFs), the URL here is the saved omnivore link in GCS
url: String!
pageType: PageType!
contentReader: ContentReader!
createdAt: Date!
isArchived: Boolean!
readingProgressPercent: Float
readingProgressAnchorIndex: Int
author: String
image: String
description: String
publishedAt: Date
ownedByViewer: Boolean
# for uploaded file articles (PDFs), we track the original article URL separately!
originalArticleUrl: String
uploadFileId: ID
# used for highlights
pageId: ID
shortId: String
quote: String
annotation: String
labels: [Label!]
}
type SearchItemEdge {
cursor: String!
node: SearchItem!
}
type SearchSuccess {
edges: [SearchItemEdge!]!
pageInfo: PageInfo!
}
enum SearchErrorCode {
UNAUTHORIZED
}
type SearchError {
errorCodes: [SearchErrorCode!]!
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -1410,10 +1499,12 @@ const schema = gql`
deleteReminder(id: ID!): DeleteReminderResult!
setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult!
createLabel(input: CreateLabelInput!): CreateLabelResult!
updateLabel(input: UpdateLabelInput!): UpdateLabelResult!
deleteLabel(id: ID!): DeleteLabelResult!
login(input: LoginInput!): LoginResult!
signup(input: SignupInput!): SignupResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
generateApiKey(scope: String): GenerateApiKeyResult!
}
# FIXME: remove sort from feedArticles after all cahced tabs are closed
@ -1450,6 +1541,7 @@ const schema = gql`
newsletterEmails: NewsletterEmailsResult!
reminder(linkId: ID!): ReminderResult!
labels: LabelsResult!
search(after: String, first: Int, query: String): SearchResult!
}
`

View file

@ -19,7 +19,7 @@ import { articleRouter } from './routers/article_router'
import { mobileAuthRouter } from './routers/auth/mobile/mobile_auth_router'
import { contentServiceRouter } from './routers/svc/content'
import { localDebugRouter } from './routers/local_debug_router'
import { Connection, createConnection } from 'typeorm'
import { DataSource } from 'typeorm'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import { linkServiceRouter } from './routers/svc/links'
import UserModel from './datalayer/user'
@ -40,7 +40,7 @@ import { ApolloServer } from 'apollo-server-express'
import { pdfAttachmentsRouter } from './routers/svc/pdf_attachments'
import { corsConfig } from './utils/corsConfig'
import { initElasticsearch } from './elastic'
import { pageServiceRouter } from './routers/svc/pages'
import { uploadServiceRouter } from './routers/svc/upload'
const PORT = process.env.PORT || 4000
@ -57,21 +57,19 @@ export const initModels = (kx: Knex, cache = true): DataModels => ({
reminder: new ReminderModel(kx, cache),
})
const initEntities = async (): Promise<Connection> => {
return createConnection({
type: 'postgres',
host: env.pg.host,
port: env.pg.port,
schema: 'omnivore',
username: env.pg.userName,
password: env.pg.password,
database: env.pg.dbName,
logging: ['query', 'info'],
entities: [__dirname + '/entity/**/*{.js,.ts}'],
subscribers: [__dirname + '/events/**/*{.js,.ts}'],
namingStrategy: new SnakeNamingStrategy(),
})
}
export const AppDataSource = new DataSource({
type: 'postgres',
host: env.pg.host,
port: env.pg.port,
schema: 'omnivore',
username: env.pg.userName,
password: env.pg.password,
database: env.pg.dbName,
logging: ['query', 'info'],
entities: [__dirname + '/entity/**/*{.js,.ts}'],
subscribers: [__dirname + '/events/**/*{.js,.ts}'],
namingStrategy: new SnakeNamingStrategy(),
})
export const createApp = (): {
app: Express
@ -100,7 +98,7 @@ export const createApp = (): {
app.use('/svc/pubsub/links', linkServiceRouter())
app.use('/svc/pubsub/newsletters', newsletterServiceRouter())
app.use('/svc/pubsub/emails', emailsServiceRouter())
app.use('/svc/pubsub/pages', pageServiceRouter())
app.use('/svc/pubsub/upload', uploadServiceRouter())
app.use('/svc/reminders', remindersServiceRouter())
app.use('/svc/pdf-attachments', pdfAttachmentsRouter())
@ -126,7 +124,7 @@ const main = async (): Promise<void> => {
// If creating the DB entities fails, we want this to throw
// so the container will be restarted and not come online
// as healthy.
await initEntities()
await AppDataSource.initialize()
await initElasticsearch()

View file

@ -1,13 +1,13 @@
import { getManager } from 'typeorm'
import { Link } from '../entity/link'
import { setClaims } from '../entity/utils'
import { AppDataSource } from '../server'
export const setLinkArchived = async (
userId: string,
linkId: string,
archived: boolean
): Promise<void> => {
await getManager().transaction(async (t) => {
await AppDataSource.transaction(async (t) => {
await setClaims(t, userId)
await t.getRepository(Link).update(
{

View file

@ -1,9 +1,9 @@
import { getManager } from 'typeorm'
import { User } from '../entity/user'
import { Group } from '../entity/groups/group'
import { Invite } from '../entity/groups/invite'
import { GroupMembership } from '../entity/groups/group_membership'
import { nanoid } from 'nanoid'
import { AppDataSource } from '../server'
export const createGroup = async (input: {
admin: User
@ -11,15 +11,12 @@ export const createGroup = async (input: {
maxMembers?: number
expiresInDays?: number
}): Promise<[Group, Invite]> => {
const [group, invite] = await getManager().transaction<[Group, Invite]>(
const [group, invite] = await AppDataSource.transaction<[Group, Invite]>(
async (t) => {
const group = await t
.getRepository(Group)
.create({
name: input.name,
createdBy: input.admin,
})
.save()
const group = await t.getRepository(Group).save({
name: input.name,
createdBy: input.admin,
})
const code = nanoid(8)
const expirationTime = (() => {
@ -27,25 +24,19 @@ export const createGroup = async (input: {
r.setDate(r.getDate() + (input.expiresInDays || 7))
return r
})()
const invite = await t
.getRepository(Invite)
.create({
group,
code,
createdBy: input.admin,
maxMembers: input.maxMembers || 50,
expirationTime: expirationTime,
})
.save()
const invite = await t.getRepository(Invite).save({
group,
code,
createdBy: input.admin,
maxMembers: input.maxMembers || 50,
expirationTime: expirationTime,
})
// Add the admin to the group as its first user
await t
.getRepository(GroupMembership)
.create({
user: input.admin,
group,
invite,
})
.save()
await t.getRepository(GroupMembership).save({
user: input.admin,
group,
invite,
})
return [group, invite]
}
)

View file

@ -9,7 +9,7 @@ import {
} from '../generated/graphql'
import { articleSavingRequestDataToArticleSavingRequest } from '../utils/helpers'
import * as privateIpLib from 'private-ip'
import { countByCreatedAt } from '../elastic'
import { countByCreatedAt } from '../elastic/pages'
const isPrivateIP = privateIpLib.default

View file

@ -1,12 +1,14 @@
import { AuthProvider } from '../routers/auth/auth_types'
import { MembershipTier } from '../datalayer/user/model'
import { EntityManager, getManager, getRepository } from 'typeorm'
import { EntityManager } from 'typeorm'
import { User } from '../entity/user'
import { Profile } from '../entity/profile'
import { SignupErrorCode } from '../generated/graphql'
import { validateUsername } from '../utils/usernamePolicy'
import { Invite } from '../entity/groups/invite'
import { GroupMembership } from '../entity/groups/group_membership'
import { AppDataSource } from '../server'
import { getRepository } from '../entity/utils'
export const createUser = async (input: {
provider: AuthProvider
@ -28,15 +30,12 @@ export const createUser = async (input: {
}
// create profile if user exists but profile does not exist
const profile = await getManager()
.getRepository(Profile)
.create({
username: input.username,
pictureUrl: input.pictureUrl,
bio: input.bio,
user: existingUser,
})
.save()
const profile = await getRepository(Profile).save({
username: input.username,
pictureUrl: input.pictureUrl,
bio: input.bio,
user: existingUser,
})
return [existingUser, profile]
}
@ -45,10 +44,10 @@ export const createUser = async (input: {
return Promise.reject({ errorCode: SignupErrorCode.InvalidUsername })
}
const [user, profile] = await getManager().transaction<[User, Profile]>(
const [user, profile] = await AppDataSource.transaction<[User, Profile]>(
async (t) => {
let hasInvite = false
let invite: Invite | undefined = undefined
let invite: Invite | null = null
if (input.inviteCode) {
const inviteCodeRepo = t.getRepository(Invite)
@ -60,37 +59,28 @@ export const createUser = async (input: {
hasInvite = true
}
}
const user = await t
.getRepository(User)
.create({
source: input.provider,
membership:
input.membershipTier ||
(hasInvite ? MembershipTier.Beta : MembershipTier.WaitList),
name: input.name,
email: input.email,
sourceUserId: input.sourceUserId,
password: input.password,
})
.save()
const profile = await t
.getRepository(Profile)
.create({
username: input.username,
pictureUrl: input.pictureUrl,
bio: input.bio,
user,
})
.save()
const user = await t.getRepository(User).save({
source: input.provider,
membership:
input.membershipTier ||
(hasInvite ? MembershipTier.Beta : MembershipTier.WaitList),
name: input.name,
email: input.email,
sourceUserId: input.sourceUserId,
password: input.password,
})
const profile = await t.getRepository(Profile).save({
username: input.username,
pictureUrl: input.pictureUrl,
bio: input.bio,
user,
})
if (hasInvite && invite) {
await t
.getRepository(GroupMembership)
.create({
user: user,
invite: invite,
group: invite.group,
})
.save()
await t.getRepository(GroupMembership).save({
user: user,
invite: invite,
group: invite.group,
})
}
return [user, profile]
}
@ -119,7 +109,7 @@ const validateInvite = async (
return true
}
const getUser = async (email: string): Promise<User | undefined> => {
const getUser = async (email: string): Promise<User | null> => {
const userRepo = getRepository(User)
return userRepo.findOne({

View file

@ -1,6 +1,6 @@
import { getRepository } from 'typeorm'
import { User } from '../entity/user'
import { Follower } from '../entity/follower'
import { getRepository } from '../entity/utils'
export const getUserFollowers = async (
user: User,
@ -9,7 +9,7 @@ export const getUserFollowers = async (
): Promise<User[]> => {
return (
await getRepository(Follower).find({
where: { user: user },
where: { user: { id: user.id } },
relations: ['user', 'followee'],
skip: offset,
take: count,
@ -24,7 +24,7 @@ export const getUserFollowing = async (
): Promise<User[]> => {
return (
await getRepository(Follower).find({
where: { followee: user },
where: { followee: { id: user.id } },
relations: ['user', 'followee'],
skip: offset,
take: count,

View file

@ -1,10 +1,11 @@
import DataLoader from 'dataloader'
import { Label } from '../entity/label'
import { getRepository, ILike, In } from 'typeorm'
import { Link } from '../entity/link'
import { ILike, In } from 'typeorm'
import { PageContext } from '../elastic/types'
import { User } from '../entity/user'
import { addLabelInPage } from '../elastic'
import { addLabelInPage } from '../elastic/labels'
import { getRepository } from '../entity/utils'
import { Link } from '../entity/link'
import DataLoader from 'dataloader'
const batchGetLabelsFromLinkIds = async (
linkIds: readonly string[]
@ -30,13 +31,16 @@ export const addLabelToPage = async (
description?: string
}
): Promise<boolean> => {
const user = await getRepository(User).findOne(ctx.uid)
const user = await getRepository(User).findOneBy({
id: ctx.uid,
})
if (!user) {
return false
}
let labelEntity = await getRepository(Label).findOne({
where: {
user: user,
name: ILike(label.name),
},
let labelEntity = await getRepository(Label).findOneBy({
user: { id: user.id },
name: ILike(label.name),
})
if (!labelEntity) {

View file

@ -1,14 +1,24 @@
import { getRepository } from 'typeorm'
import { NewsletterEmail } from '../entity/newsletter_email'
import { nanoid } from 'nanoid'
import { User } from '../entity/user'
import { CreateNewsletterEmailErrorCode } from '../generated/graphql'
import { env } from '../env'
import { getRepository } from '../entity/utils'
import addressparser = require('nodemailer/lib/addressparser')
const parsedAddress = (emailAddress: string): string | undefined => {
const res = addressparser(emailAddress, { flatten: true })
if (!res || res.length < 1) {
return undefined
}
return res[0].address
}
export const createNewsletterEmail = async (
userId: string
): Promise<NewsletterEmail> => {
const user = await getRepository(User).findOne(userId, {
const user = await getRepository(User).findOne({
where: { id: userId },
relations: ['profile'],
})
if (!user) {
@ -19,19 +29,17 @@ export const createNewsletterEmail = async (
// generate a random email address with username prefix
const emailAddress = createRandomEmailAddress(user.profile.username, 8)
return getRepository(NewsletterEmail)
.create({
address: emailAddress,
user: user,
})
.save()
return getRepository(NewsletterEmail).save({
address: emailAddress,
user: user,
})
}
export const getNewsletterEmails = async (
userId: string
): Promise<NewsletterEmail[]> => {
return getRepository(NewsletterEmail).find({
where: { user: userId },
where: { user: { id: userId } },
order: { createdAt: 'DESC' },
})
}
@ -46,9 +54,10 @@ export const updateConfirmationCode = async (
emailAddress: string,
confirmationCode: string
): Promise<boolean> => {
const address = parsedAddress(emailAddress)
const result = await getRepository(NewsletterEmail)
.createQueryBuilder()
.where('address ILIKE :address', { address: emailAddress })
.where('address ILIKE :address', { address })
.update({
confirmationCode: confirmationCode,
})
@ -59,11 +68,12 @@ export const updateConfirmationCode = async (
export const getNewsletterEmail = async (
emailAddress: string
): Promise<NewsletterEmail | undefined> => {
): Promise<NewsletterEmail | null> => {
const address = parsedAddress(emailAddress)
return getRepository(NewsletterEmail)
.createQueryBuilder('newsletter_email')
.innerJoinAndSelect('newsletter_email.user', 'user')
.where('address ILIKE :address', { address: emailAddress })
.where('address ILIKE :address', { address })
.getOne()
}

View file

@ -1,8 +1,8 @@
import { getRepository } from 'typeorm'
import { ReportItemInput, ReportType } from '../generated/graphql'
import { ContentDisplayReport } from '../entity/reports/content_display_report'
import { AbuseReport } from '../entity/reports/abuse_report'
import { getPageById } from '../elastic'
import { getPageById } from '../elastic/pages'
import { getRepository } from '../entity/utils'
export const saveContentDisplayReport = async (
uid: string,
@ -20,16 +20,14 @@ export const saveContentDisplayReport = async (
// We capture the article content and original html now, in case it
// reparsed or updated later, this gives us a view of exactly
// what the user saw.
const result = await repo
.create({
userId: uid,
elasticPageId: input.pageId,
content: page.content,
originalHtml: page.originalHtml || undefined,
originalUrl: page.url,
reportComment: input.reportComment,
})
.save()
const result = await repo.save({
userId: uid,
elasticPageId: input.pageId,
content: page.content,
originalHtml: page.originalHtml || undefined,
originalUrl: page.url,
reportComment: input.reportComment,
})
return !!result
}
@ -55,16 +53,14 @@ export const saveAbuseReport = async (
// We capture the article content and original html now, in case it
// reparsed or updated later, this gives us a view of exactly
// what the user saw.
const result = await repo
.create({
reportedBy: uid,
sharedBy: input.sharedBy,
elasticPageId: input.pageId,
itemUrl: input.itemUrl,
reportTypes: [ReportType.Abusive],
reportComment: input.reportComment,
})
.save()
const result = await repo.save({
reportedBy: uid,
sharedBy: input.sharedBy,
elasticPageId: input.pageId,
itemUrl: input.itemUrl,
reportTypes: [ReportType.Abusive],
reportComment: input.reportComment,
})
return !!result
}

View file

@ -7,11 +7,12 @@ import {
import normalizeUrl from 'normalize-url'
import { PubsubClient } from '../datalayer/pubsub'
import { Page } from '../elastic/types'
import { createPage, getPageByParam, updatePage } from '../elastic'
import { createPage, getPageByParam, updatePage } from '../elastic/pages'
export type SaveContext = {
pubsub: PubsubClient
uid: string
refresh?: boolean
}
export type SaveEmailInput = {
@ -67,7 +68,7 @@ export const saveEmail = async (
readingProgressPercent: 0,
}
const page = await getPageByParam({ url: articleToSave.url })
const page = await getPageByParam({ userId: ctx.uid, url: articleToSave.url })
if (page) {
const result = await updatePage(page.id, { archivedAt: null }, ctx)
console.log('updated page from email', result)
@ -82,7 +83,6 @@ export const saveEmail = async (
return undefined
}
console.log('created new page from email', pageId)
articleToSave.id = pageId
return articleToSave

Some files were not shown because too many files have changed in this diff Show more