resolve merge conflict

This commit is contained in:
Satindar Dhillon 2022-04-06 12:21:47 -07:00
commit 5ab64f3f64
166 changed files with 3731 additions and 1073 deletions

View file

@ -69,7 +69,6 @@ jobs:
yarn build
yarn lint
yarn test
env:
PG_HOST: localhost
PG_PORT: ${{ job.services.postgres.ports[5432] }}
@ -78,3 +77,12 @@ jobs:
PG_DB: omnivore_test
PG_POOL_MAX: 10
ELASTIC_URL: http://localhost:${{ job.services.elastic.ports[9200] }}/
build-docker-images:
name: Build docker images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Build the API docker image
run: 'docker build --file packages/api/Dockerfile .'

View file

@ -0,0 +1,87 @@
import Combine
import Models
import Services
import SwiftUI
import Views
final class ApplyLabelsViewModel: ObservableObject {
private var hasLoadedInitialLabels = false
@Published var isLoading = true
@Published var selectedLabels = Set<FeedItemLabel>()
@Published var labels = [FeedItemLabel]()
var subscriptions = Set<AnyCancellable>()
func load(item: FeedItem, dataService: DataService) {
guard !hasLoadedInitialLabels else { return }
dataService.labelsPublisher().sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] result in
self?.isLoading = false
self?.labels = result
self?.hasLoadedInitialLabels = true
self?.selectedLabels = Set(item.labels)
}
)
.store(in: &subscriptions)
}
func saveChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) {
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink(
receiveCompletion: { _ in },
receiveValue: { onComplete($0) }
)
.store(in: &subscriptions)
}
}
struct ApplyLabelsView: View {
let item: FeedItem
let commitLabelChanges: ([FeedItemLabel]) -> Void
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = ApplyLabelsViewModel()
var body: some View {
NavigationView {
if viewModel.isLoading {
EmptyView()
} else {
List(viewModel.labels, id: \.self, selection: $viewModel.selectedLabels) { label in
if let textChip = TextChip(feedItemLabel: label) {
textChip
} else {
Text(label.name)
}
}
.environment(\.editMode, .constant(EditMode.active))
.navigationTitle("Apply Labels")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text("Cancel") }
)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: {
viewModel.saveChanges(itemID: item.id, dataService: dataService) { labels in
commitLabelChanges(labels)
presentationMode.wrappedValue.dismiss()
}
},
label: { Text("Save") }
)
}
}
}
}
.onAppear {
viewModel.load(item: item, dataService: dataService)
}
}
}

View file

@ -36,7 +36,6 @@ struct GridCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@State private var scale = 1.0
@State private var isActive = false
let item: FeedItem
let searchQuery: String
@ -51,7 +50,8 @@ struct GridCardNavigationLink: View {
ZStack {
NavigationLink(
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)),
isActive: $isActive
tag: item,
selection: $selectedLinkItem
) {
EmptyView()
}
@ -60,7 +60,7 @@ struct GridCardNavigationLink: View {
scale = 0.95
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) {
scale = 1.0
isActive = true
selectedLinkItem = item
}
}
})
@ -68,7 +68,7 @@ struct GridCardNavigationLink: View {
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
}
}
.aspectRatio(2.1, contentMode: .fill)
.aspectRatio(1.8, contentMode: .fill)
.scaleEffect(scale)
}
}

View file

@ -100,6 +100,9 @@ import Views
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
}
}
.onChange(of: selectedLinkItem) { _ in
viewModel.commitProgressUpdates()
}
}
}
@ -164,6 +167,11 @@ import Views
}
}
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(item: item) { labels in
viewModel.updateLabels(itemID: item.id, labels: labels)
}
}
}
}
}
@ -320,13 +328,15 @@ 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,

View file

@ -8,9 +8,13 @@ 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]()
@Published var items = [FeedItem]()
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: FeedItem?
var cursor: String?
var sendProgressUpdates = false
@ -76,6 +80,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 +167,27 @@ final class HomeFeedViewModel: ObservableObject {
.store(in: &subscriptions)
}
func updateProgress(itemID: String, progress: Double) {
/// Update `FeedItem`s with the cached reading progress 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 commitProgressUpdates() {
for (key, value) in uncommittedReadingProgressUpdates {
updateProgress(itemID: key, progress: value)
}
uncommittedReadingProgressUpdates = [:]
}
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]) {
guard let item = items.first(where: { $0.id == itemID }) else { return }
if let index = items.firstIndex(of: item) {
items[index].labels = labels
}
}
}

View file

@ -92,7 +92,7 @@ final class LinkItemDetailViewModel: ObservableObject {
case let .shareHighlight(highlightID):
print("show share modal for highlight with id: \(highlightID)")
case let .updateReadingProgess(progress: progress):
self?.homeFeedViewModel.updateProgress(itemID: self?.item.id ?? "", progress: Double(progress))
self?.homeFeedViewModel.uncommittedReadingProgressUpdates[self?.item.id ?? ""] = Double(progress)
}
}
.store(in: &newWebAppWrapperViewModel.subscriptions)

View file

@ -0,0 +1,195 @@
import Combine
import Models
import Services
import SwiftUI
import Views
final class LabelsViewModel: ObservableObject {
private var hasLoadedInitialLabels = false
@Published var isLoading = false
@Published var labels = [FeedItemLabel]()
@Published var showCreateEmailModal = false
var subscriptions = Set<AnyCancellable>()
func loadLabels(dataService: DataService) {
guard !hasLoadedInitialLabels else { return }
isLoading = true
dataService.labelsPublisher().sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] result in
self?.isLoading = false
self?.labels = result
self?.hasLoadedInitialLabels = true
}
)
.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?.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)
}
}
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("Remove Link", 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) }
}
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")
Spacer()
}
}
)
.disabled(viewModel.isLoading)
}
if !viewModel.labels.isEmpty {
Section(header: Text("Labels")) {
ForEach(viewModel.labels, id: \.id) { label in
HStack {
Text(label.name)
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 body: some View {
NavigationView {
VStack(spacing: 16) {
TextField("Label Name", text: $newLabelName)
.keyboardType(.alphabet)
.textFieldStyle(StandardTextFieldStyle())
ColorPicker(
newLabelColor == .clear ? "Select Color" : newLabelColor.description,
selection: $newLabelColor
)
Button(
action: {
viewModel.createLabel(
dataService: dataService,
name: newLabelName,
color: newLabelColor,
description: nil
)
},
label: { Text("Create") }
)
.buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300))
.disabled(viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear)
Spacer()
}
.padding()
.toolbar {
ToolbarItem(placement: .automatic) {
Button(
action: { viewModel.showCreateEmailModal = false },
label: {
Image(systemName: "xmark")
.foregroundColor(.appGrayTextContrast)
}
)
}
}
.navigationTitle("Create New Label")
.navigationBarTitleDisplayMode(.inline)
}
}
}

View file

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

View file

@ -29,17 +29,6 @@ struct WebReader: UIViewRepresentable {
let webView = WebViewManager.shared()
let contentController = WKUserContentController()
webView.loadHTMLString(
WebReaderContent(
articleContent: articleContent,
item: item,
isDark: UITraitCollection.current.userInterfaceStyle == .dark,
fontSize: fontSize()
)
.styledContent,
baseURL: ViewsPackage.bundleURL
)
webView.navigationDelegate = context.coordinator
webView.isOpaque = false
webView.backgroundColor = .clear
@ -63,6 +52,7 @@ struct WebReader: UIViewRepresentable {
context.coordinator.linkHandler = openLinkAction
context.coordinator.webViewActionHandler = webViewActionHandler
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater
loadContent(webView: webView)
return webView
}
@ -82,5 +72,39 @@ struct WebReader: UIViewRepresentable {
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()
)
.styledContent,
baseURL: ViewsPackage.bundleURL
)
}
}

View file

@ -36,7 +36,7 @@ struct WebReaderContainerView: View {
let messageBody = message.body as? [String: Double]
if let messageBody = messageBody, let progress = messageBody["progress"] {
homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress))
homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress)
}
}
@ -56,7 +56,7 @@ struct WebReaderContainerView: View {
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))
homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress)
}
}
@ -204,7 +204,7 @@ struct WebReaderContainerView: View {
Color.systemBackground
.transition(.opacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withAnimation(.linear(duration: 0.2)) {
showOverlay = false
}

View file

@ -22,7 +22,11 @@ struct WebReaderContent {
// swiftlint:disable line_length
var styledContent: String {
"""
let savedAt = "new Date(\(item.savedAt.timeIntervalSince1970 * 1000)).toISOString()"
let createdAt = "new Date(\(item.createdAt.timeIntervalSince1970 * 1000)).toISOString()"
let publishedAt = item.publishDate != nil ? "new Date(\(item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" : "undefined"
return """
<!DOCTYPE html>
<html>
<head>
@ -37,9 +41,6 @@ struct WebReaderContent {
<div id='_omnivore-htmlContent' style="display: none;">
\(articleContent.htmlContent)
</div>
<div id='_omnivore-title' style="display: none;">
\(item.title)
</div>
<script type="text/javascript">
window.omnivoreEnv = {
"NEXT_PUBLIC_APP_ENV": "prod",
@ -52,10 +53,11 @@ struct WebReaderContent {
id: "\(item.id)",
linkId: "\(item.id)",
slug: "\(item.slug)",
createdAt: new Date().toISOString(),
savedAt: new Date().toISOString(),
createdAt: \(createdAt),
savedAt: \(savedAt),
publishedAt: \(publishedAt),
url: `\(item.pageURLString)`,
title: document.getElementById('_omnivore-title').innerHTML,
title: `\(item.title.replacingOccurrences(of: "`", with: "\\`"))`,
content: document.getElementById('_omnivore-htmlContent').innerHTML,
originalArticleUrl: "\(item.pageURLString)",
contentReader: "WEB",

View file

@ -12,7 +12,7 @@ typealias WKScriptMessageReplyHandler = (Any?, String?) -> Void
final class WebReaderCoordinator: NSObject {
var webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void = { _, _ in }
var linkHandler: (URL) -> Void = { _ in }
var needsReload = true
var needsReload = false
var lastSavedAnnotationID: UUID?
var previousIncreaseFontActionID: UUID?
var previousDecreaseFontActionID: UUID?
@ -69,6 +69,10 @@ extension WebReaderCoordinator: WKNavigationDelegate {
webView.backgroundColor = .systemBackground
#endif
}
func webViewWebContentProcessDidTerminate(_: WKWebView) {
needsReload = true
}
}
#if os(iOS)

View file

@ -10,24 +10,28 @@ struct SafariWebLink: Identifiable {
}
func encodeHighlightResult(_ highlight: Highlight) -> [String: Any]? {
let data = try? JSONEncoder().encode(highlight)
if let data = data, let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] {
return dictionary
}
return nil
guard let data = try? JSONEncoder().encode(highlight) else { return nil }
return try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]
}
final class WebReaderViewModel: ObservableObject {
@Published var isLoading = false
@Published var articleContent: ArticleContent?
var slug: String?
var subscriptions = Set<AnyCancellable>()
func loadContent(dataService: DataService, slug: String) {
self.slug = slug
isLoading = true
guard let viewer = dataService.currentViewer else { return }
if let content = dataService.pageFromCache(slug: slug) {
articleContent = content
// continue to load from the web if possible
}
dataService.articleContentPublisher(username: viewer.username, slug: slug).sink(
receiveCompletion: { [weak self] completion in
guard case .failure = completion else { return }
@ -35,6 +39,7 @@ final class WebReaderViewModel: ObservableObject {
},
receiveValue: { [weak self] articleContent in
self?.articleContent = articleContent
dataService.pageCache.setObject(CachedPageContent(slug, articleContent), forKey: NSString(string: slug))
}
)
.store(in: &subscriptions)
@ -167,12 +172,16 @@ final class WebReaderViewModel: ObservableObject {
switch actionID {
case "deleteHighlight":
dataService.invalidateCachedPage(slug: slug)
deleteHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "createHighlight":
dataService.invalidateCachedPage(slug: slug)
createHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "mergeHighlight":
dataService.invalidateCachedPage(slug: slug)
mergeHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "updateHighlight":
dataService.invalidateCachedPage(slug: slug)
updateHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)
case "articleReadingProgress":
updateReadingProgress(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService)

View file

@ -1,5 +1,15 @@
import Foundation
public class CachedPageContent: NSObject {
public let slug: String
public let value: ArticleContent
public init(_ slug: String, _ content: ArticleContent) {
self.slug = slug
self.value = content
}
}
public struct ArticleContent {
public let htmlContent: String
public let highlights: [Highlight]

View file

@ -12,8 +12,9 @@ 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
public var readingProgress: Double
public var readingProgressAnchor: Int
public let imageURLString: String?
@ -27,10 +28,13 @@ 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,
title: String,
createdAt: Date,
savedAt: Date,
readingProgress: Double,
readingProgressAnchor: Int,
imageURLString: String?,
@ -43,10 +47,13 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
publishDate: Date?,
slug: String,
isArchived: Bool,
contentReader: String?
contentReader: String?,
labels: [FeedItemLabel]
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.savedAt = savedAt
self.readingProgress = readingProgress
self.readingProgressAnchor = readingProgressAnchor
self.imageURLString = imageURLString
@ -60,16 +67,20 @@ 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, 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 {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
createdAt = try container.decode(Date.self, forKey: .createdAt)
savedAt = try container.decode(Date.self, forKey: .savedAt)
description = try container.decode(String?.self, forKey: .title)
imageURLString = try container.decode(String?.self, forKey: .image)
readingProgress = try container.decode(Double.self, forKey: .readingProgressPercent)
@ -78,6 +89,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
contentReader = try container.decode(String.self, forKey: .contentReader)
pageURLString = try container.decode(String.self, forKey: .url)
isArchived = try container.decode(Bool.self, forKey: .isArchived)
labels = try container.decode([FeedItemLabel].self, forKey: .labels)
self.onDeviceImageURLString = nil
self.documentDirectoryPath = nil

View file

@ -0,0 +1,23 @@
import Foundation
public struct FeedItemLabel: Decodable, Hashable {
public let id: String
public let name: String
public let color: String
public let createdAt: Date?
public let description: String?
public init(
id: String,
name: String,
color: String,
createdAt: Date?,
description: String?
) {
self.id = id
self.name = name
self.color = color
self.createdAt = createdAt
self.description = description
}
}

View file

@ -1,6 +1,16 @@
import Combine
import Foundation
import Models
public class CacheManager: NSObject, NSCacheDelegate {
public func cache(_: NSCache<AnyObject, AnyObject>, willEvictObject obj: Any) {
// This is just used for debugging
if let content = obj as? CachedPageContent {
print("evicting page from cache", content.slug)
}
}
}
public final class DataService: ObservableObject {
public static var registerIntercomUser: ((String) -> Void)?
public static var showIntercomMessenger: (() -> Void)?
@ -9,12 +19,20 @@ public final class DataService: ObservableObject {
public internal(set) var currentViewer: Viewer?
let networker: Networker
public let pageCache = NSCache<NSString, CachedPageContent>()
let pageCacheQueue = DispatchQueue.global(qos: .background)
let highlightsCache = NSCache<AnyObject, CachedPDFHighlights>()
let highlightsCacheQueue = DispatchQueue(label: "app.omnivore.highlights.cache.queue", attributes: .concurrent)
let cacheManager: CacheManager
var subscriptions = Set<AnyCancellable>()
public init(appEnvironment: AppEnvironment, networker: Networker) {
self.appEnvironment = appEnvironment
self.networker = networker
self.cacheManager = CacheManager()
pageCache.delegate = cacheManager
}
public func clearHighlights() {
@ -30,3 +48,37 @@ public final class DataService: ObservableObject {
}
}
}
public extension DataService {
func prefetchPages(items: [FeedItem]) {
print("prefetching pages")
guard let viewer = currentViewer else { return }
for item in items {
let slug = item.slug
articleContentPublisher(username: viewer.username, slug: slug).sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] articleContent in
self?.pageCache.setObject(CachedPageContent(slug, articleContent), forKey: NSString(string: slug))
}
)
.store(in: &subscriptions)
}
}
func pageFromCache(slug: String) -> ArticleContent? {
if let content = pageCache.object(forKey: NSString(string: slug)) {
print("cache hit", slug)
return content.value
} else {
print("cache miss", slug)
}
return nil
}
func invalidateCachedPage(slug: String?) {
if let slug = slug {
pageCache.removeObject(forKey: NSString(string: slug))
}
}
}

View file

@ -2970,8 +2970,11 @@ extension Objects {
let savedByViewer: [String: Bool]
let shareInfo: [String: Objects.LinkShareInfo]
let sharedComment: [String: String]
let siteIcon: [String: String]
let siteName: [String: String]
let slug: [String: String]
let title: [String: String]
let uploadFileId: [String: String]
let url: [String: String]
enum TypeName: String, Codable {
@ -3088,6 +3091,14 @@ extension Objects.Article: Decodable {
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "siteIcon":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "siteName":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "slug":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -3096,6 +3107,10 @@ extension Objects.Article: Decodable {
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "uploadFileId":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "url":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -3134,8 +3149,11 @@ extension Objects.Article: Decodable {
savedByViewer = map["savedByViewer"]
shareInfo = map["shareInfo"]
sharedComment = map["sharedComment"]
siteIcon = map["siteIcon"]
siteName = map["siteName"]
slug = map["slug"]
title = map["title"]
uploadFileId = map["uploadFileId"]
url = map["url"]
}
}
@ -3587,6 +3605,51 @@ extension Fields where TypeLock == Objects.Article {
return selection.mock()
}
}
func uploadFileId() throws -> String? {
let field = GraphQLField.leaf(
name: "uploadFileId",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
return data.uploadFileId[field.alias!]
case .mocking:
return nil
}
}
func siteName() throws -> String? {
let field = GraphQLField.leaf(
name: "siteName",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
return data.siteName[field.alias!]
case .mocking:
return nil
}
}
func siteIcon() throws -> String? {
let field = GraphQLField.leaf(
name: "siteIcon",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
return data.siteIcon[field.alias!]
case .mocking:
return nil
}
}
}
extension Selection where TypeLock == Never, Type == Never {
@ -10784,7 +10847,7 @@ extension Fields where TypeLock == Objects.Label {
}
}
func createdAt() throws -> DateTime {
func createdAt() throws -> DateTime? {
let field = GraphQLField.leaf(
name: "createdAt",
arguments: []
@ -10793,12 +10856,9 @@ extension Fields where TypeLock == Objects.Label {
switch response {
case let .decoding(data):
if let data = data.createdAt[field.alias!] {
return data
}
throw HttpError.badpayload
return data.createdAt[field.alias!]
case .mocking:
return DateTime.mockValue
return nil
}
}
}
@ -16779,6 +16839,10 @@ extension Enums {
/// SortBy
enum SortBy: String, CaseIterable, Codable {
case updatedTime = "UPDATED_TIME"
case score = "SCORE"
case savedAt = "SAVED_AT"
}
}
@ -16956,6 +17020,8 @@ extension Enums {
case payloadTooLarge = "PAYLOAD_TOO_LARGE"
case uploadFileMissing = "UPLOAD_FILE_MISSING"
case elasticError = "ELASTIC_ERROR"
}
}

View file

@ -0,0 +1,62 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func createLabelPublisher(
name: String,
color: String,
description: String?
) -> AnyPublisher<FeedItemLabel, BasicError> {
enum MutationResult {
case saved(label: FeedItemLabel)
case error(errorCode: Enums.CreateLabelErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateLabelResult> {
try $0.on(
createLabelSuccess: .init { .saved(label: try $0.label(selection: feedItemLabelSelection)) },
createLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.createLabel(
input: InputObjects.CreateLabelInput(
name: name,
color: color,
description: OptionalArgument(description)
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
switch payload.data {
case let .saved(label: label):
promise(.success(label))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,53 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func removeLabelPublisher(labelID: String) -> AnyPublisher<Bool, BasicError> {
enum MutationResult {
case success(labelID: String)
case error(errorCode: Enums.DeleteLabelErrorCode)
}
let selection = Selection<MutationResult, Unions.DeleteLabelResult> {
try $0.on(
deleteLabelSuccess: .init {
.success(labelID: try $0.label(selection: Selection.Label { try $0.id() }))
},
deleteLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.deleteLabel(id: labelID, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if payload.errors != nil {
promise(.failure(.message(messageText: "Error removing label")))
}
switch payload.data {
case .success:
promise(.success(true))
case .error:
promise(.failure(.message(messageText: "Error removing label")))
}
case .failure:
promise(.failure(.message(messageText: "Error removing label")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,57 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func updateArticleLabelsPublisher(itemID: String, labelIDs: [String]) -> AnyPublisher<[FeedItemLabel], BasicError> {
enum MutationResult {
case saved(feedItem: [FeedItemLabel])
case error(errorCode: Enums.SetLabelsErrorCode)
}
let selection = Selection<MutationResult, Unions.SetLabelsResult> {
try $0.on(
setLabelsSuccess: .init { .saved(feedItem: try $0.labels(selection: feedItemLabelSelection.list)) },
setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.setLabels(
input: InputObjects.SetLabelsInput(
linkId: itemID,
labelIds: labelIDs
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: graphqlError.first.debugDescription)))
}
switch payload.data {
case let .saved(labels):
promise(.success(labels))
case .error:
promise(.failure(.message(messageText: "failed to set labels")))
}
case .failure:
promise(.failure(.message(messageText: "failed to set labels")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -3,7 +3,6 @@ import Foundation
import Models
import SwiftGraphQL
// swiftlint:disable:next function_body_length
public extension DataService {
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<ArticleContent, ServerError> {
enum QueryResult {

View file

@ -0,0 +1,49 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func labelsPublisher() -> AnyPublisher<[FeedItemLabel], ServerError> {
enum QueryResult {
case success(result: [FeedItemLabel])
case error(error: String)
}
let selection = Selection<QueryResult, Unions.LabelsResult> {
try $0.on(labelsSuccess: .init {
QueryResult.success(result: try $0.labels(selection: feedItemLabelSelection.list))
},
labelsError: .init {
QueryResult.error(error: try $0.errorCodes().description)
})
}
let query = Selection.Query {
try $0.labels(selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(query, to: path, headers: headers) { result in
switch result {
case let .success(payload):
switch payload.data {
case let .success(result: result):
promise(.success(result))
case .error:
promise(.failure(.unknown))
}
case .failure:
promise(.failure(.unknown))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -129,6 +129,8 @@ let homeFeedItemSelection = Selection.Article {
FeedItem(
id: try $0.id(),
title: try $0.title(),
createdAt: try $0.createdAt().value ?? Date(),
savedAt: try $0.savedAt().value ?? Date(),
readingProgress: try $0.readingProgressPercent(),
readingProgressAnchor: try $0.readingProgressAnchorIndex(),
imageURLString: try $0.image(),
@ -141,7 +143,8 @@ let homeFeedItemSelection = Selection.Article {
publishDate: try $0.publishedAt()?.value,
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue
contentReader: try $0.contentReader().rawValue,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}

View file

@ -0,0 +1,12 @@
import Models
import SwiftGraphQL
let feedItemLabelSelection = Selection.Label {
FeedItemLabel(
id: try $0.id(),
name: try $0.name(),
color: try $0.color(),
createdAt: try $0.createdAt()?.value,
description: try $0.description()
)
}

View file

@ -0,0 +1,63 @@
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? {
let uic = UIColor(self)
guard let components = uic.cgColor.components, components.count >= 3 else {
return nil
}
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)
)
}
}

View file

@ -14,6 +14,6 @@ public enum FeatureFlag {
public static let enablePushNotifications = false
public static let enableShareButton = false
public static let enableSnooze = false
public static let showFeedItemTags = false
public static let enableLabels = true
public static let useLocalWebView = true
}

View file

@ -10,7 +10,8 @@ enum WebViewConfigurationManager {
static func create() -> WKWebViewConfiguration {
let config = WKWebViewConfiguration()
config.processPool = processPool
// config.limitsNavigationsToAppBoundDomains = true
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = .audio
return config
}
}

View file

@ -5,6 +5,7 @@ import Utils
public enum GridCardAction {
case toggleArchiveStatus
case delete
case editLabels
}
public struct GridCard: View {
@ -42,6 +43,10 @@ public struct GridCard: View {
var contextMenuView: some View {
Group {
Button(
action: { menuActionHandler(.editLabels) },
label: { Label("Edit Labels", systemImage: "tag") }
)
Button(
action: { menuActionHandler(.toggleArchiveStatus) },
label: {
@ -156,11 +161,12 @@ public struct GridCard: View {
.onTapGesture { tapHandler() }
// Category Labels
if FeatureFlag.showFeedItemTags {
if FeatureFlag.enableLabels {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
TextChip(text: "label", color: .red)
TextChip(text: "longer label", color: .blue)
ForEach(item.labels, id: \.self) {
TextChip(feedItemLabel: $0)
}
Spacer()
}
.frame(height: 30)

View file

@ -1,11 +1,25 @@
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)

View file

@ -84,6 +84,7 @@ services:
- PG_PORT=5432
- PG_POOL_MAX=20
- ELASTIC_URL=http://elastic:9200
- JAEGER_HOST=jaeger
- IMAGE_PROXY_URL=http://localhost:9999
- IMAGE_PROXY_SECRET=some-secret
- JWT_SECRET=some_secret

View file

@ -8,7 +8,7 @@
],
"license": "UNLICENSED",
"scripts": {
"test": "lerna run test --ignore @omnivore/web",
"test": "lerna run --no-bail test --ignore @omnivore/web",
"lint": "lerna run lint --ignore @omnivore/web",
"build": "lerna run build --ignore @omnivore/web",
"bootstrap": "lerna bootstrap",

View file

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

View file

@ -2,7 +2,6 @@ FROM node:14.18-alpine as builder
WORKDIR /app
ENV NODE_ENV production
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
COPY package.json .
@ -14,7 +13,7 @@ COPY .eslintrc .
COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json
COPY /packages/api/package.json ./packages/api/package.json
RUN yarn install --pure-lockfile --production
RUN yarn install --pure-lockfile
ADD /packages/readabilityjs ./packages/readabilityjs
ADD /packages/api ./packages/api
@ -22,7 +21,10 @@ ADD /packages/api ./packages/api
RUN yarn
RUN yarn workspace @omnivore/api build
# After building, fetch the production dependencies
RUN rm -rf /app/packages/api/node_modules
RUN rm -rf /app/node_modules
RUN yarn install --pure-lockfile --production
FROM node:14.18-alpine as runner
@ -38,7 +40,7 @@ COPY --from=builder /app/packages/api/package.json /app/packages/api/package.jso
COPY --from=builder /app/packages/api/node_modules /app/packages/api/node_modules
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/packages/api/index_settings.json /app/packages/api/index_settings.json
EXPOSE 8080
CMD ["yarn", "workspace", "@omnivore/api", "start"]

View file

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

View file

@ -25,6 +25,7 @@ import {
} 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'
@ -170,7 +171,6 @@ export const createPage = async (
body: {
...page,
updatedAt: new Date(),
createdAt: new Date(),
savedAt: new Date(),
},
refresh: ctx.refresh,
@ -210,6 +210,13 @@ export const updatePage = async (
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
}
@ -485,6 +492,44 @@ export const searchPages = async (
}
}
export const countByCreatedAt = async (
userId: string,
from?: number,
to?: number
): Promise<number> => {
try {
const { body } = await client.count({
index: INDEX_ALIAS,
body: {
query: {
bool: {
filter: [
{
term: {
userId,
},
},
{
range: {
createdAt: {
gte: from,
lte: to,
},
},
},
],
},
},
},
})
return body.count as number
} catch (e) {
console.error('failed to count pages in elastic', e)
return 0
}
}
export const initElasticsearch = async (): Promise<void> => {
try {
const response = await client.info()

View file

@ -142,6 +142,7 @@ export interface Page {
archivedAt?: Date | null
siteName?: string
_id?: string
siteIcon?: string
}
const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId'] as const

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +1,19 @@
import {
Entity,
BaseEntity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { ReportType } from '../../generated/graphql'
@Entity()
export class AbuseReport extends BaseEntity {
export class AbuseReport {
@PrimaryGeneratedColumn('uuid')
id?: string
@Column('text')
pageId!: string
pageId?: string
@Column('text')
sharedBy!: string
@ -36,4 +35,7 @@ export class AbuseReport extends BaseEntity {
@UpdateDateColumn()
updatedAt?: Date
@Column('text')
elasticPageId?: string
}

View file

@ -1,14 +1,13 @@
import {
Entity,
BaseEntity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
@Entity()
export class ContentDisplayReport extends BaseEntity {
export class ContentDisplayReport {
@PrimaryGeneratedColumn('uuid')
id?: string
@ -16,7 +15,7 @@ export class ContentDisplayReport extends BaseEntity {
userId!: string
@Column('text')
pageId!: string
pageId?: string
@Column('text')
content!: string
@ -35,4 +34,7 @@ export class ContentDisplayReport extends BaseEntity {
@UpdateDateColumn()
updatedAt?: Date
@Column('text')
elasticPageId?: string
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -66,6 +66,8 @@ export type Article = {
savedByViewer?: Maybe<Scalars['Boolean']>;
sharedComment?: Maybe<Scalars['String']>;
shareInfo?: Maybe<LinkShareInfo>;
siteIcon?: Maybe<Scalars['String']>;
siteName?: Maybe<Scalars['String']>;
slug: Scalars['String'];
title: Scalars['String'];
uploadFileId?: Maybe<Scalars['ID']>;
@ -2406,6 +2408,8 @@ export type ArticleResolvers<ContextType = ResolverContext, ParentType extends R
savedByViewer?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
sharedComment?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
shareInfo?: Resolver<Maybe<ResolversTypes['LinkShareInfo']>, ParentType, ContextType>;
siteIcon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
siteName?: 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>;

View file

@ -47,6 +47,8 @@ type Article {
savedByViewer: Boolean
sharedComment: String
shareInfo: LinkShareInfo
siteIcon: String
siteName: String
slug: String!
title: String!
uploadFileId: ID

View file

@ -157,6 +157,8 @@ declare module '@omnivore/readability' {
excerpt: string
/** Article site name */
siteName: string
/** Article site icon */
siteIcon: string
/** Article preview image */
previewImage?: string
/** Article published date */

View file

@ -272,6 +272,7 @@ export const createArticleResolver = authorized<
createdAt: saveTime,
savedAt: saveTime,
siteName: parsedContent?.siteName,
siteIcon: parsedContent?.siteIcon,
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
}

View file

@ -20,10 +20,11 @@ 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 { ILike, In } from 'typeorm'
import { getRepository, setClaims } from '../../entity/utils'
import { deleteLabelInPages, getPageById, updatePage } from '../../elastic'
import { createPubSubClient } from '../../datalayer/pubsub'
import { AppDataSource } from '../../server'
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
async (_obj, _params, { claims: { uid }, log }) => {
@ -38,7 +39,8 @@ export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
})
try {
const user = await User.findOne(uid, {
const user = await getRepository(User).findOne({
where: { id: uid },
relations: ['labels'],
})
if (!user) {
@ -69,7 +71,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 +79,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,14 +89,12 @@ 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,
@ -128,14 +126,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 +149,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)
})
@ -197,7 +196,7 @@ export const setLabelsResolver = authorized<
const { linkId: 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 +210,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) {

View file

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

View file

@ -45,7 +45,7 @@ export const reportItemResolver: ResolverFn<
const uid = ctx.claims?.uid || ''
const value = { sharedBy: uid, reportedBy: uid, ...args.input }
const report = await saveAbuseReport(ctx.kx, uid, value)
const report = await saveAbuseReport(uid, value)
const message = report ? SUCCESS_MESSAGE : FAILURE_MESSAGE
return {
message: message,
@ -58,7 +58,7 @@ export const reportItemResolver: ResolverFn<
message: FAILURE_MESSAGE,
}
}
const report = await saveContentDisplayReport(ctx.kx, uid, args.input)
const report = await saveContentDisplayReport(uid, args.input)
const message = report ? SUCCESS_MESSAGE : FAILURE_MESSAGE
return {
message: message,

View file

@ -47,13 +47,7 @@ export function articleRouter() {
const requestId = uuidv4()
const models = initModels(kx, false)
const result = await createPageSaveRequest(
uid,
url,
models,
'high',
requestId
)
const result = await createPageSaveRequest(uid, url, models, requestId)
if (isSiteBlockedForParse(url)) {
return res

View file

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

View file

@ -42,12 +42,7 @@ export function linkServiceRouter() {
const models = initModels(kx, false)
try {
const request = await createPageSaveRequest(
msg.userId,
msg.url,
models,
'low'
)
const request = await createPageSaveRequest(msg.userId, msg.url, models)
console.log('create link request', request)
res.status(200).send(request)

View file

@ -338,6 +338,8 @@ const schema = gql`
linkId: ID
labels: [Label!]
uploadFileId: ID
siteName: String
siteIcon: String
}
# Query: article

View file

@ -19,7 +19,7 @@ import { articleRouter } from './routers/article_router'
import { mobileAuthRouter } from './routers/auth/mobile/mobile_auth_router'
import { contentServiceRouter } from './routers/svc/content'
import { localDebugRouter } from './routers/local_debug_router'
import { Connection, createConnection } from 'typeorm'
import { DataSource } from 'typeorm'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import { linkServiceRouter } from './routers/svc/links'
import UserModel from './datalayer/user'
@ -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
@ -126,7 +124,7 @@ const main = async (): Promise<void> => {
// If creating the DB entities fails, we want this to throw
// so the container will be restarted and not come online
// as healthy.
await initEntities()
await AppDataSource.initialize()
await initElasticsearch()

View file

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

View file

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

View file

@ -9,9 +9,19 @@ import {
} from '../generated/graphql'
import { articleSavingRequestDataToArticleSavingRequest } from '../utils/helpers'
import * as privateIpLib from 'private-ip'
import { countByCreatedAt } from '../elastic'
const isPrivateIP = privateIpLib.default
// 5 articles added in the last minute: use low queue
// default: use normal queue
const getPriorityByRateLimit = async (
userId: string
): Promise<'low' | 'high'> => {
const count = await countByCreatedAt(userId, Date.now() - 60 * 1000)
return count >= 5 ? 'low' : 'high'
}
export const validateUrl = (url: string): URL => {
const u = new URL(url)
// Make sure the URL is http or https
@ -43,8 +53,8 @@ export const createPageSaveRequest = async (
userId: string,
url: string,
models: DataModels,
priority: 'low' | 'high' = 'high',
articleSavingRequestId = uuidv4()
articleSavingRequestId = uuidv4(),
priority?: 'low' | 'high'
): Promise<ArticleSavingRequest> => {
try {
validateUrl(url)
@ -63,6 +73,9 @@ export const createPageSaveRequest = async (
})
}
// get priority by checking rate limit if not specified
priority = priority || (await getPriorityByRateLimit(userId))
const createdTaskName = await enqueueParseRequest(
url,
userId,

View file

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

View file

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

View file

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

View file

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

View file

@ -1,54 +1,47 @@
import { getRepository } from 'typeorm'
import { ReportItemInput, ReportType } from '../generated/graphql'
import { ContentDisplayReport } from '../entity/reports/content_display_report'
import ArticleModel from '../datalayer/article'
import Knex from 'knex'
import { AbuseReport } from '../entity/reports/abuse_report'
import { getPageById } from '../elastic'
import { getRepository } from '../entity/utils'
export const saveContentDisplayReport = async (
kx: Knex,
uid: string,
input: ReportItemInput
): Promise<boolean> => {
const repo = getRepository(ContentDisplayReport)
const am = new ArticleModel(kx)
const article = await am.get(input.pageId)
const page = await getPageById(input.pageId)
if (!article) {
console.log('unable to submit report, article not found', input)
if (!page) {
console.log('unable to submit report, page not found', input)
return false
}
// 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,
pageId: input.pageId,
content: article.content,
originalHtml: article.originalHtml || undefined,
originalUrl: article.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
}
export const saveAbuseReport = async (
kx: Knex,
uid: string,
input: ReportItemInput
): Promise<boolean> => {
const repo = getRepository(AbuseReport)
const am = new ArticleModel(kx)
const article = await am.get(input.pageId)
const page = await getPageById(input.pageId)
if (!article) {
console.log('unable to submit report, article not found', input)
if (!page) {
console.log('unable to submit report, page not found', input)
return false
}
@ -60,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,
pageId: input.pageId,
itemUrl: input.itemUrl,
reportTypes: [ReportType.Abusive],
reportComment: input.reportComment,
})
.save()
const result = await repo.save({
reportedBy: uid,
sharedBy: input.sharedBy,
elasticPageId: input.pageId,
itemUrl: input.itemUrl,
reportTypes: [ReportType.Abusive],
reportComment: input.reportComment,
})
return !!result
}

View file

@ -12,6 +12,7 @@ import { createPage, getPageByParam, updatePage } from '../elastic'
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

View file

@ -22,7 +22,8 @@ interface NewsletterMessage {
// Returns true if the link was created successfully. Can still fail to
// send the push but that is ok and we wont retry in that case.
export const saveNewsletterEmail = async (
data: NewsletterMessage
data: NewsletterMessage,
ctx?: SaveContext
): Promise<boolean> => {
// get user from newsletter email
const newsletterEmail = await getNewsletterEmail(data.email)
@ -43,7 +44,7 @@ export const saveNewsletterEmail = async (
},
})
const ctx: SaveContext = {
const saveCtx = ctx || {
pubsub: createPubSubClient(),
uid: newsletterEmail.user.id,
}
@ -55,14 +56,14 @@ export const saveNewsletterEmail = async (
author: data.author,
}
const page = await saveEmail(ctx, input)
const page = await saveEmail(saveCtx, input)
if (!page) {
console.log('newsletter not created:', input)
return false
}
// add newsletters label to page
const result = await addLabelToPage(ctx, page.id, {
const result = await addLabelToPage(saveCtx, page.id, {
name: 'Newsletter',
color: '#07D2D1',
})

View file

@ -20,7 +20,6 @@ export const saveUrl = async (
saver.id,
input.url,
ctx.models,
'high',
input.clientRequestId
)
} catch (error) {

View file

@ -1,34 +1,36 @@
import { getManager, getRepository } from 'typeorm'
import { UserDeviceToken } from '../entity/user_device_tokens'
import { User } from '../entity/user'
import { SetDeviceTokenErrorCode } from '../generated/graphql'
import { setClaims } from '../entity/utils'
import { getRepository, setClaims } from '../entity/utils'
import { analytics } from '../utils/analytics'
import { env } from '../env'
import { AppDataSource } from '../server'
export const getDeviceToken = async (
id: string
): Promise<UserDeviceToken | undefined> => {
return getRepository(UserDeviceToken).findOne(id)
): Promise<UserDeviceToken | null> => {
return getRepository(UserDeviceToken).findOneBy({ id })
}
export const getDeviceTokenByToken = async (
token: string
): Promise<UserDeviceToken | undefined> => {
return getRepository(UserDeviceToken).findOne({ token })
): Promise<UserDeviceToken | null> => {
return getRepository(UserDeviceToken).findOneBy({ token })
}
export const getDeviceTokensByUserId = async (
userId: string
): Promise<UserDeviceToken[] | undefined> => {
return getRepository(UserDeviceToken).find({ where: { user: userId } })
return getRepository(UserDeviceToken).find({
where: { user: { id: userId } },
})
}
export const createDeviceToken = async (
userId: string,
token: string
): Promise<UserDeviceToken> => {
const user = await getRepository(User).findOne(userId)
const user = await getRepository(User).findOneBy({ id: userId })
if (!user) {
return Promise.reject({
errorCode: SetDeviceTokenErrorCode.Unauthorized,
@ -43,19 +45,17 @@ export const createDeviceToken = async (
},
})
return getRepository(UserDeviceToken)
.create({
token: token,
user: user,
})
.save()
return getRepository(UserDeviceToken).save({
token: token,
user: user,
})
}
export const deleteDeviceToken = async (
id: string,
userId: string
): Promise<boolean> => {
const user = await getRepository(User).findOne(userId)
const user = await getRepository(User).findOneBy({ id: userId })
if (!user) {
return Promise.reject({
errorCode: SetDeviceTokenErrorCode.Unauthorized,
@ -70,7 +70,7 @@ export const deleteDeviceToken = async (
},
})
return getManager().transaction(async (t) => {
return AppDataSource.transaction(async (t) => {
await setClaims(t, userId)
const result = await t.getRepository(UserDeviceToken).delete(id)

View file

@ -204,6 +204,7 @@ export const deleteTask = async (
* @param url - URL address of the article to parse
* @param userId - Id of the user authorized
* @param saveRequestId - Id of the article_saving_request table record
* @param priority - Priority of the task
* @returns Name of the task created
*/
export const enqueueParseRequest = async (

View file

@ -286,6 +286,7 @@ export const parsePreparedContent = async (
title: article?.title || jsonLdLinkMetadata.title,
previewImage: article?.previewImage || jsonLdLinkMetadata.previewImage,
siteName: article?.siteName || jsonLdLinkMetadata.siteName,
siteIcon: article?.siteIcon,
byline: article?.byline || jsonLdLinkMetadata.byline,
})
logRecord.parseSuccess = true

View file

@ -1,13 +1,5 @@
import {
createConnection,
getConnection,
getManager,
getRepository,
} from 'typeorm'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import Postgrator from 'postgrator'
import { User } from '../src/entity/user'
import { createUser } from '../src/services/create_user'
import { Profile } from '../src/entity/profile'
import { Page } from '../src/entity/page'
import { Link } from '../src/entity/link'
@ -15,6 +7,10 @@ import { Reminder } from '../src/entity/reminder'
import { NewsletterEmail } from '../src/entity/newsletter_email'
import { UserDeviceToken } from '../src/entity/user_device_tokens'
import { Label } from '../src/entity/label'
import { AppDataSource } from '../src/server'
import { getRepository } from '../src/entity/utils'
import { createUser } from '../src/services/create_user'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
const runMigrations = async () => {
const migrationDirectory = __dirname + '/../../db/migrations'
@ -43,8 +39,10 @@ const runMigrations = async () => {
}
}
const createEntityConnection = async (): Promise<void> => {
await createConnection({
export const createTestConnection = async (): Promise<void> => {
await runMigrations()
AppDataSource.setOptions({
type: 'postgres',
host: process.env.PG_HOST,
port: Number(process.env.PG_PORT),
@ -57,21 +55,11 @@ const createEntityConnection = async (): Promise<void> => {
subscribers: [__dirname + '/../src/events/**/*{.js,.ts}'],
namingStrategy: new SnakeNamingStrategy(),
})
}
export const createTestConnection = async (): Promise<void> => {
try {
getConnection()
// eslint-disable-next-line no-empty
} catch (error) {}
await runMigrations()
await createEntityConnection()
await AppDataSource.initialize()
}
export const deleteTestUser = async (name: string) => {
await getConnection()
.createQueryBuilder()
await AppDataSource.createQueryBuilder()
.delete()
.from(User)
.where({ email: `${name}@fake.com` })
@ -93,68 +81,58 @@ export const createTestUser = async (
inviteCode: invite,
password: password,
})
return newUser
}
export const createUserWithoutProfile = async (name: string): Promise<User> => {
return getManager()
.getRepository(User)
.create({
source: 'GOOGLE',
sourceUserId: 'fake-user-id-' + name,
email: `${name}@fake.com`,
name: name,
})
.save()
return getRepository(User).save({
source: 'GOOGLE',
sourceUserId: 'fake-user-id-' + name,
email: `${name}@fake.com`,
name: name,
})
}
export const getProfile = async (user: User): Promise<Profile | undefined> => {
return Profile.findOne({ where: { user: user } })
export const getProfile = async (user: User): Promise<Profile | null> => {
return getRepository(Profile).findOneBy({ user: { id: user.id } })
}
export const createTestPage = async (): Promise<Page> => {
return getRepository(Page)
.create({
originalHtml: 'html',
content: 'Test content',
description: 'Test description',
title: 'Test title',
author: 'Test author',
url: 'Test url',
hash: 'Test hash',
})
.save()
return getRepository(Page).save({
originalHtml: 'html',
content: 'Test content',
description: 'Test description',
title: 'Test title',
author: 'Test author',
url: 'Test url',
hash: 'Test hash',
})
}
export const createTestLink = async (user: User, page: Page): Promise<Link> => {
return getRepository(Link)
.create({
user: user,
page: page,
slug: 'Test slug',
articleUrl: 'Test url',
articleHash: 'Test hash',
})
.save()
return getRepository(Link).save({
user: user,
page: page,
slug: 'Test slug',
articleUrl: 'Test url',
articleHash: 'Test hash',
})
}
export const createTestReminder = async (
user: User,
link?: string
): Promise<Reminder> => {
return getRepository(Reminder)
.create({
user: user,
link: link,
remindAt: new Date(),
})
.save()
return getRepository(Reminder).save({
user: user,
link: link,
remindAt: new Date(),
})
}
export const getReminder = async (
id: string
): Promise<Reminder | undefined> => {
return getRepository(Reminder).findOne(id)
export const getReminder = async (id: string): Promise<Reminder | null> => {
return getRepository(Reminder).findOneBy({ id })
}
export const createTestNewsletterEmail = async (
@ -162,44 +140,40 @@ export const createTestNewsletterEmail = async (
emailAddress?: string,
confirmationCode?: string
): Promise<NewsletterEmail> => {
return getRepository(NewsletterEmail)
.create({
user: user,
address: emailAddress,
confirmationCode: confirmationCode,
})
.save()
return getRepository(NewsletterEmail).save({
user: user,
address: emailAddress,
confirmationCode: confirmationCode,
})
}
export const getNewsletterEmail = async (
id: string
): Promise<NewsletterEmail | undefined> => {
return getRepository(NewsletterEmail).findOne(id)
): Promise<NewsletterEmail | null> => {
return getRepository(NewsletterEmail).findOneBy({ id })
}
export const createTestDeviceToken = async (
user: User
): Promise<UserDeviceToken> => {
return getRepository(UserDeviceToken)
.create({
user: user,
token: 'Test token',
})
.save()
return getRepository(UserDeviceToken).save({
user: user,
token: 'Test token',
})
}
export const getDeviceToken = async (
id: string
): Promise<UserDeviceToken | undefined> => {
return getRepository(UserDeviceToken).findOne(id)
): Promise<UserDeviceToken | null> => {
return getRepository(UserDeviceToken).findOneBy({ id })
}
export const getUser = async (id: string): Promise<User | undefined> => {
return getRepository(User).findOne(id)
export const getUser = async (id: string): Promise<User | null> => {
return getRepository(User).findOneBy({ id })
}
export const getLink = async (id: string): Promise<Link | undefined> => {
return getRepository(Link).findOne(id)
export const getLink = async (id: string): Promise<Link | null> => {
return getRepository(Link).findOneBy({ id })
}
export const createTestLabel = async (
@ -207,11 +181,9 @@ export const createTestLabel = async (
name: string,
color: string
): Promise<Label> => {
return getRepository(Label)
.create({
user: user,
name: name,
color: color,
})
.save()
return getRepository(Label).save({
user: user,
name: name,
color: color,
})
}

View file

@ -1,6 +1,7 @@
import 'mocha'
import {
addLabelInPage,
countByCreatedAt,
createPage,
deletePage,
getPageById,
@ -112,7 +113,6 @@ describe('elastic api', () => {
describe('getPageById', () => {
it('gets a page by id', async () => {
const pageFound = await getPageById(page.id)
expect(pageFound).not.undefined
})
})
@ -127,7 +127,6 @@ describe('elastic api', () => {
await updatePage(page.id, updatedPageData, ctx)
const updatedPage = await getPageById(page.id)
expect(updatedPage?.title).to.eql(newTitle)
})
})
@ -178,4 +177,31 @@ describe('elastic api', () => {
})
})
})
describe('countByCreatedAt', () => {
const createdAt = Date.now() - 60 * 60 * 24 * 1000
before(async () => {
const newPageData: Page = {
id: '',
hash: 'hash',
userId: userId,
pageType: PageType.Article,
title: 'test',
content: 'test',
slug: 'test',
createdAt: new Date(createdAt),
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
url: 'https://blog.omnivore.app/testCount',
}
await createPage(newPageData, ctx)
})
it('counts pages by createdAt', async () => {
const count = await countByCreatedAt(userId, createdAt, createdAt)
expect(count).to.eq(1)
})
})
})

View file

@ -1,6 +1,6 @@
import { getConnection } from 'typeorm'
import { AppDataSource } from '../src/server'
export const mochaGlobalTeardown = async () => {
await getConnection().close()
await AppDataSource.destroy()
console.log('db connection closed')
}

View file

@ -1,12 +1,5 @@
import { createTestUser, deleteTestUser, getProfile, getUser } from '../db'
import { createTestUser, deleteTestUser } from '../db'
import { graphqlRequest, request } from '../util'
import { expect } from 'chai'
import {
LoginErrorCode,
SignupErrorCode,
UpdateUserErrorCode,
UpdateUserProfileErrorCode,
} from '../../src/generated/graphql'
import { User } from '../../src/entity/user'
import { hashPassword } from '../../src/utils/auth'
import 'mocha'
@ -33,7 +26,7 @@ describe('Sanitize Directive', () => {
})
describe('Update user with a bio that is too long', () => {
let bio = "".padStart(500, '*');
let bio = ''.padStart(500, '*')
let query: string
beforeEach(() => {

View file

@ -19,9 +19,9 @@ import {
} from '../../src/elastic'
import { PageType, UploadFileStatus } from '../../src/generated/graphql'
import { Page, PageContext } from '../../src/elastic/types'
import { getRepository } from 'typeorm'
import { UploadFile } from '../../src/entity/upload_file'
import { createPubSubClient } from '../../src/datalayer/pubsub'
import { getRepository } from '../../src/entity/utils'
chai.use(chaiString)

View file

@ -137,7 +137,7 @@ describe('Highlights API', () => {
before(async () => {
// create test highlight
highlightId = generateFakeUuid()
const shortHighlightId = '_short_id'
const shortHighlightId = '_short_id_1'
const query = createHighlightQuery(
authToken,
pageId,
@ -149,7 +149,7 @@ describe('Highlights API', () => {
it('should not fail', async () => {
const newHighlightId = generateFakeUuid()
const newShortHighlightId = '_short_id_1'
const newShortHighlightId = '_short_id_2'
const query = mergeHighlightQuery(
pageId,
newHighlightId,

View file

@ -7,11 +7,11 @@ import {
} from '../util'
import { Label } from '../../src/entity/label'
import { expect } from 'chai'
import { getRepository } from 'typeorm'
import 'mocha'
import { User } from '../../src/entity/user'
import { Page } from '../../src/elastic/types'
import { getPageById } from '../../src/elastic'
import { getRepository } from '../../src/entity/utils'
describe('Labels API', () => {
const username = 'fakeUser'
@ -76,7 +76,9 @@ describe('Labels API', () => {
it('should return labels', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
const labels = await getRepository(Label).find({ where: { user } })
const labels = await getRepository(Label).findBy({
user: { id: user.id },
})
expect(res.body.data.labels.labels).to.eql(
labels.map((label) => ({
id: label.id,
@ -137,9 +139,9 @@ describe('Labels API', () => {
it('should create label', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
const label = await getRepository(Label).findOne(
res.body.data.createLabel.label.id
)
const label = await getRepository(Label).findOneBy({
id: res.body.data.createLabel.label.id,
})
expect(label).to.exist
})
})
@ -203,7 +205,7 @@ describe('Labels API', () => {
it('should delete label', async () => {
await graphqlRequest(query, authToken).expect(200)
const label = await getRepository(Label).findOne(labelId)
const label = await getRepository(Label).findOneBy({ id: labelId })
expect(label).to.not.exist
})
})

View file

@ -28,11 +28,11 @@ describe('Newsletters API', () => {
// create test newsletter emails
const newsletterEmail1 = await createTestNewsletterEmail(
user,
'Test_email_address_1'
'Test_email_address_1@fake-email.com'
)
const newsletterEmail2 = await createTestNewsletterEmail(
user,
'Test_email_address_2'
'Test_email_address_2@fake-email.com'
)
newsletterEmails = [newsletterEmail1, newsletterEmail2]
})
@ -164,7 +164,7 @@ describe('Newsletters API', () => {
const newsletterEmail = await getNewsletterEmail(
response.body.data.deleteNewsletterEmail.newsletterEmail.id
)
expect(newsletterEmail).to.be.undefined
expect(newsletterEmail).to.be.null
})
})

View file

@ -0,0 +1,80 @@
import { User } from '../../src/entity/user'
import { Page } from '../../src/elastic/types'
import { createTestUser, deleteTestUser } from '../db'
import { createTestElasticPage, graphqlRequest, request } from '../util'
import { ReportType } from '../../src/generated/graphql'
import { ContentDisplayReport } from '../../src/entity/reports/content_display_report'
import { expect } from 'chai'
import { getRepository } from '../../src/entity/utils'
describe('Report API', () => {
const username = 'fakeUser'
let user: User
let authToken: string
let page: Page
before(async () => {
// create test user and login
user = await createTestUser(username)
const res = await request
.post('/local/debug/fake-user-login')
.send({ fakeEmail: user.email })
authToken = res.body.authToken
// create a page
page = await createTestElasticPage(user)
})
after(async () => {
// clean up
await deleteTestUser(username)
})
describe('reportItem', () => {
let pageId: string
let reportTypes: ReportType[]
let query: string
beforeEach(() => {
query = `
mutation {
reportItem(
input: {
pageId: "${pageId}",
itemUrl: "test url"
reportTypes: [${reportTypes}],
reportComment: "test comment"
}
) {
message
}
}
`
})
context('when page exists and report is content display', () => {
before(() => {
pageId = page.id
reportTypes = [ReportType.ContentDisplay]
})
after(async () => {
await getRepository(ContentDisplayReport).delete({
elasticPageId: pageId,
})
})
it('should report an item', async () => {
await graphqlRequest(query, authToken).expect(200)
expect(
await getRepository(ContentDisplayReport).findBy({
elasticPageId: pageId,
})
).to.exist
})
})
})
})

View file

@ -75,7 +75,7 @@ describe('Device tokens API', () => {
const deviceToken = await getDeviceToken(
response.body.data.setDeviceToken.deviceToken.id
)
expect(deviceToken).to.be.undefined
expect(deviceToken).to.be.null
})
})
@ -105,7 +105,7 @@ describe('Device tokens API', () => {
const deviceToken = await getDeviceToken(
response.body.data.setDeviceToken.deviceToken.id
)
expect(deviceToken).not.to.be.undefined
expect(deviceToken).not.to.be.null
})
})

View file

@ -10,8 +10,8 @@ import { SharedArticleErrorCode } from '../../src/generated/graphql'
import { Page } from '../../src/entity/page'
import { Link } from '../../src/entity/link'
import { Highlight } from '../../src/entity/highlight'
import { getRepository } from 'typeorm'
import 'mocha'
import { getRepository } from '../../src/entity/utils'
describe('User feed article API', () => {
const existingUsername = 'fakeUser'

View file

@ -3,6 +3,7 @@ import { request } from '../util'
import { expect } from 'chai'
import nock from 'nock'
import 'mocha'
import { env } from '../../src/env'
describe('/article/save API', () => {
const username = 'fakeUser'
@ -12,7 +13,7 @@ describe('/article/save API', () => {
// We need to mock the pupeeteer-parse
// service here because in dev mode the task gets
// called immediately.
nock('http://localhost:8080/').post('/').reply(200)
nock(env.queue.puppeteerTaskHanderUrl).post('/').reply(200)
before(async () => {
// create test user and login

View file

@ -12,7 +12,7 @@ import { getPageById } from '../../src/elastic'
describe('PDF attachments Router', () => {
const username = 'fakeUser'
const newsletterEmail = 'fakeEmail'
const newsletterEmail = 'fakeEmail@fake-email.com'
let user: User
let authToken: string

View file

@ -1,7 +1,6 @@
import 'mocha'
import { expect } from 'chai'
import 'chai/register-should'
import { labelsLoader } from '../../src/services/labels'
import {
createTestLabel,
createTestLink,
@ -9,10 +8,11 @@ import {
createTestUser,
deleteTestUser,
} from '../db'
import { getRepository } from 'typeorm'
import { LinkLabel } from '../../src/entity/link_label'
import { Label } from '../../src/entity/label'
import { Link } from '../../src/entity/link'
import { labelsLoader } from '../../src/services/labels'
import { getRepository } from '../../src/entity/utils'
describe('batch get labels from linkIds', () => {
let username = 'testUser'

View file

@ -17,6 +17,7 @@ describe('saveEmail', () => {
const ctx: SaveContext = {
pubsub: createPubSubClient(),
uid: user.id,
refresh: true,
}
await saveEmail(ctx, {
@ -36,15 +37,11 @@ describe('saveEmail', () => {
})
expect(secondResult).to.not.be.undefined
setTimeout(async () => {
const page = await getPageByParam({ userId: user.id })
if (!page) {
expect.fail('page not found')
}
expect(page.url).to.equal('https://example.com')
expect(page.title).to.equal('fake title')
expect(page.author).to.equal('fake author')
expect(page.content).to.contain('fake content')
})
const page = await getPageByParam({ userId: user.id })
expect(page).to.exist
expect(page?.url).to.equal('https://example.com')
expect(page?.title).to.equal('fake title')
expect(page?.author).to.equal('fake author')
expect(page?.content).to.contain('fake content')
})
})

View file

@ -7,16 +7,24 @@ import { saveNewsletterEmail } from '../../src/services/save_newsletter_email'
import { getPageByParam } from '../../src/elastic'
import { User } from '../../src/entity/user'
import { NewsletterEmail } from '../../src/entity/newsletter_email'
import { SaveContext } from '../../src/services/save_email'
import { createPubSubClient } from '../../src/datalayer/pubsub'
describe('saveNewsletterEmail', () => {
const username = 'fakeUser'
let user: User
let email: NewsletterEmail
let ctx: SaveContext
before(async () => {
user = await createTestUser(username)
email = await createNewsletterEmail(user.id)
ctx = {
pubsub: createPubSubClient(),
refresh: true,
uid: user.id,
}
})
after(async () => {
@ -30,7 +38,7 @@ describe('saveNewsletterEmail', () => {
url: 'https://example.com',
title: 'fake title',
author: 'fake author',
})
}, ctx)
setTimeout(async () => {
const page = await getPageByParam({ userId: user.id })
@ -56,7 +64,7 @@ describe('saveNewsletterEmail', () => {
url: 'https://example.com/2',
title: 'fake title',
author: 'fake author',
})
}, ctx)
setTimeout(async () => {
const page = await getPageByParam({ userId: user.id })

View file

@ -4,7 +4,7 @@ import { v4 } from 'uuid'
import { corsConfig } from '../src/utils/corsConfig'
import { Page } from '../src/elastic/types'
import { PageType } from '../src/generated/graphql'
import { createPage } from '../src/elastic'
import { createPage, getPageById } from '../src/elastic'
import { User } from '../src/entity/user'
import { Label } from '../src/entity/label'
import { createPubSubClient } from '../src/datalayer/pubsub'
@ -62,5 +62,11 @@ export const createTestElasticPage = async (
if (pageId) {
page.id = pageId
}
return page
const res = await getPageById(page.id)
console.log('got page', res)
if (!res) {
throw new Error('Failed to create page')
}
return res
}

View file

@ -30,12 +30,22 @@ describe('isProbablyNewsletter', () => {
describe('findNewsletterUrl', async () => {
it('gets the URL from the header if it is a substack newsletter', async () => {
nock('https://newsletter.slowchinese.net')
.head('/p/companies-that-eat-people-217?token=eyJ1c2VyX2lkIjoxMTU0MzM0NSwicG9zdF9pZCI6NDg3MjA5NDAsImlhdCI6MTY0NTI1NzQ1MSwiaXNzIjoicHViLTI4MDUzMSIsInN1YiI6InBvc3QtcmVhY3Rpb24ifQ.l5F3Kx6K9tvy9cRAXx3MepobQBCJDJQgAxOpA0INIZA')
.reply(200, '');
const html = load('./test/utils/data/substack-forwarded-newsletter.html')
const url = await findNewsletterUrl(html)
// Not sure if the redirects from substack expire, this test could eventually fail
expect(url).to.startWith('https://newsletter.slowchinese.net/p/companies-that-eat-people-217')
})
it('gets the URL from the header if it is a beehiiv newsletter', async () => {
nock('https://u23463625.ct.sendgrid.net')
.head('/ss/c/AX1lEgEQaxtvFxLaVo0GBo_geajNrlI1TGeIcmMViR3pL3fEDZnbbkoeKcaY62QZk0KPFudUiUXc_uMLerV4nA/3k5/3TFZmreTR0qKSCgowABnVg/h30/zzLik7UXd1H_n4oyd5W8Xu639AYQQB2UXz-CsssSnno')
.reply(302, undefined,{
'Location': 'https://www.milkroad.com/p/talked-guy-spent-30m-beeple'
})
.get('/p/talked-guy-spent-30m-beeple')
.reply(200, '');
const html = load('./test/utils/data/beehiiv-newsletter.html')
const url = await findNewsletterUrl(html)
expect(url).to.startWith('https://www.milkroad.com/p/talked-guy-spent-30m-beeple')

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