mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge commit '65e233262d7c7171dceb923c5bc7a8ed41ff0be7' into OMN-SB
This commit is contained in:
commit
0b48daf28c
216 changed files with 11834 additions and 3027 deletions
10
.github/workflows/run-tests.yaml
vendored
10
.github/workflows/run-tests.yaml
vendored
|
|
@ -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 .'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
161
apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift
Normal file
161
apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift
Normal 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()) }
|
||||
}
|
||||
}
|
||||
233
apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift
Normal file
233
apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift
Normal 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"
|
||||
]
|
||||
105
apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift
Normal file
105
apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ struct ProfileView: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
if FeatureFlag.enableLabels {
|
||||
NavigationLink(destination: LabelsView()) {
|
||||
Text("Labels")
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink(destination: NewsletterEmailsView()) {
|
||||
Text("Emails")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift
Normal file
23
apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
87
apple/OmnivoreKit/Sources/Utils/ColorUtils.swift
Normal file
87
apple/OmnivoreKit/Sources/Utils/ColorUtils.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
73
docker-compose-test.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
188
packages/api/add_highlight_to_elastic.py
Normal file
188
packages/api/add_highlight_to_elastic.py
Normal 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')
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
302
packages/api/src/elastic/highlights.ts
Normal file
302
packages/api/src/elastic/highlights.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
133
packages/api/src/elastic/labels.ts
Normal file
133
packages/api/src/elastic/labels.ts
Normal 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
|
||||
}
|
||||
}
|
||||
475
packages/api/src/elastic/pages.ts
Normal file
475
packages/api/src/elastic/pages.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!]!
|
||||
}
|
||||
|
|
|
|||
40
packages/api/src/resolvers/api_key/index.ts
Normal file
40
packages/api/src/resolvers/api_key/index.ts
Normal 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] }
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 || [] })
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue