mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3245 from omnivore-app/main
Web production deployment
This commit is contained in:
commit
ed7b353a78
50 changed files with 955 additions and 510 deletions
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@State var hasHighlightMutations = false
|
||||
@State var searchPresented = false
|
||||
@State var addLinkPresented = false
|
||||
@State var settingsPresented = false
|
||||
@State var isListScrolled = false
|
||||
@State var listTitle = ""
|
||||
@State var isEditMode: EditMode = .inactive
|
||||
|
|
@ -54,7 +53,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
|
||||
var showFeatureCards: Bool {
|
||||
viewModel.listConfig.hasFeatureCards &&
|
||||
isEditMode == .inactive &&
|
||||
viewModel.listConfig.hasFeatureCards &&
|
||||
!viewModel.hideFeatureSection &&
|
||||
viewModel.fetcher.items.count > 0 &&
|
||||
viewModel.searchTerm.isEmpty &&
|
||||
|
|
@ -179,11 +179,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
LibraryAddLinkView()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $settingsPresented) {
|
||||
NavigationView {
|
||||
ProfileView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if viewModel.fetcher.items.isEmpty {
|
||||
loadItems(isRefresh: false)
|
||||
|
|
@ -210,14 +205,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
.frame(maxWidth: .infinity, alignment: .bottomLeading)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPad, viewModel.folder == "inbox" {
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus")
|
||||
})
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) {
|
||||
if enableGrid {
|
||||
Button(
|
||||
|
|
@ -232,29 +219,31 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Button(
|
||||
action: { searchPresented = true },
|
||||
label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
Image.magnifyingGlass
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPhone {
|
||||
Menu(content: {
|
||||
Button(action: {
|
||||
isEditMode = isEditMode == .inactive ? .active : .inactive
|
||||
}, label: {
|
||||
Text(isEditMode == .inactive ? "Select Multiple" : "End Multiselect")
|
||||
})
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus.circle")
|
||||
})
|
||||
Button(action: { settingsPresented = true }, label: {
|
||||
Label(LocalText.genericProfile, systemImage: "person.circle")
|
||||
})
|
||||
|
||||
}, label: {
|
||||
Image.utilityMenu
|
||||
})
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
Button(
|
||||
action: { isEditMode = isEditMode == .active ? .inactive : .active },
|
||||
label: {
|
||||
Image.selectMultiple
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if viewModel.folder == "inbox" {
|
||||
Button(
|
||||
action: { addLinkPresented = true },
|
||||
label: {
|
||||
Image.addLink
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
|
|
@ -368,7 +357,9 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var isListScrolled: Bool
|
||||
@Binding var prefersListLayout: Bool
|
||||
@Binding var isEditMode: EditMode
|
||||
@State private var showAddFeedView = false
|
||||
@State private var showHideFeatureAlert = false
|
||||
@State private var showHideFollowingAlert = false
|
||||
|
||||
@Binding var selection: Set<String>
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
|
@ -379,23 +370,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@State var topItem: Models.LibraryItem?
|
||||
@ObservedObject var networkMonitor = NetworkMonitor()
|
||||
|
||||
// init(listTitle: Binding<String>,
|
||||
// isListScrolled: Binding<Bool>,
|
||||
// prefersListLayout: Binding<Bool>,
|
||||
// isEditMode: Binding<EditMode>,
|
||||
// selection: Binding<Set<String>>,
|
||||
// viewModel: HomeFeedViewModel,
|
||||
// showFeatureCards: Bool)
|
||||
// {
|
||||
// self._listTitle = listTitle
|
||||
// self._isListScrolled = isListScrolled
|
||||
// self._prefersListLayout = prefersListLayout
|
||||
// self._isEditMode = isEditMode
|
||||
// self._selection = selection
|
||||
// self.viewModel = viewModel
|
||||
// self.showFeatureCards = showFeatureCards
|
||||
// }
|
||||
|
||||
var filtersHeader: some View {
|
||||
GeometryReader { reader in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
|
|
@ -603,8 +577,94 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
|
||||
var redactedItems: some View {
|
||||
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.unwrappedID) { _, item in
|
||||
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
|
||||
LibraryItemCard(item: item, viewer: dataService.currentViewer)
|
||||
.listRowSeparatorTint(Color.thBorderColor)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 10, trailing: horizontalInset))
|
||||
}.redacted(reason: .placeholder)
|
||||
}
|
||||
|
||||
var emptyState: some View {
|
||||
if viewModel.folder == "following" {
|
||||
return AnyView(
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("You don't have any Feed items.")
|
||||
.font(Font.system(size: 18, weight: .bold))
|
||||
|
||||
Text("Add an RSS/Atom feed")
|
||||
.foregroundColor(Color.blue)
|
||||
.onTapGesture {
|
||||
showAddFeedView = true
|
||||
}
|
||||
|
||||
Text("Hide the Following tab")
|
||||
.foregroundColor(Color.blue)
|
||||
.onTapGesture {
|
||||
showHideFollowingAlert = true
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 400)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
)
|
||||
} else {
|
||||
return AnyView(Group {
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("No results found for this query")
|
||||
.font(Font.system(size: 18, weight: .bold))
|
||||
}
|
||||
.frame(minHeight: 400)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var listItems: some View {
|
||||
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in
|
||||
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
|
||||
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
isInMultiSelectMode: viewModel.isInMultiSelectMode,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.background(GeometryReader { geometry in
|
||||
Color.clear
|
||||
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
|
||||
})
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
|
||||
if value.y < 100, value.y > 0 {
|
||||
if item.savedAt != nil, topItem != item {
|
||||
setTopItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowSeparatorTint(Color.thBorderColor)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 10, trailing: horizontalInset))
|
||||
.contextMenu {
|
||||
menuItems(for: item)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
ForEach(viewModel.listConfig.leadingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
ForEach(viewModel.listConfig.trailingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
|
||||
VStack(spacing: 0) {
|
||||
Color.systemBackground.frame(height: 1)
|
||||
ScrollViewReader { reader in
|
||||
|
|
@ -643,38 +703,13 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
|
||||
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
isInMultiSelectMode: viewModel.isInMultiSelectMode,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.background(GeometryReader { geometry in
|
||||
Color.clear
|
||||
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
|
||||
})
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
|
||||
if value.y < 100, value.y > 0 {
|
||||
if item.savedAt != nil, topItem != item {
|
||||
setTopItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowSeparatorTint(Color.thBorderColor)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 10, trailing: horizontalInset))
|
||||
.contextMenu {
|
||||
menuItems(for: item)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
ForEach(viewModel.listConfig.leadingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
ForEach(viewModel.listConfig.trailingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
}
|
||||
}
|
||||
if viewModel.showLoadingBar {
|
||||
redactedItems
|
||||
} else if viewModel.fetcher.items.isEmpty {
|
||||
emptyState
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
} else {
|
||||
listItems
|
||||
}
|
||||
}
|
||||
}, header: {
|
||||
|
|
@ -698,13 +733,26 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
shouldScrollToTop = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddFeedView) {
|
||||
NavigationView {
|
||||
LibraryAddFeedView()
|
||||
}
|
||||
}
|
||||
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
|
||||
isPresented: $showHideFeatureAlert) {
|
||||
Button("OK", role: .destructive) {
|
||||
viewModel.hideFeatureSection = true
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
|
||||
}.introspectNavigationController { nav in
|
||||
}
|
||||
.alert("The Following tab will be hidden. You can add it back from the filter settings in your profile.",
|
||||
isPresented: $showHideFollowingAlert) {
|
||||
Button("OK", role: .destructive) {
|
||||
viewModel.hideFollowingTab = true
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFollowingAlert = false }
|
||||
}
|
||||
.introspectNavigationController { nav in
|
||||
nav.navigationBar.shadowImage = UIImage()
|
||||
nav.navigationBar.setBackgroundImage(UIImage(), for: .default)
|
||||
}
|
||||
|
|
@ -863,13 +911,24 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) {
|
||||
ForEach(viewModel.fetcher.items) { item in
|
||||
GridCardNavigationLink(
|
||||
item: item,
|
||||
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
|
||||
isContextMenuOpen: $isContextMenuOpen,
|
||||
viewModel: viewModel
|
||||
)
|
||||
if viewModel.showLoadingBar {
|
||||
ForEach(fakeLibraryItems(dataService: dataService)) { item in
|
||||
GridCardNavigationLink(
|
||||
item: item,
|
||||
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
|
||||
isContextMenuOpen: $isContextMenuOpen,
|
||||
viewModel: viewModel
|
||||
)
|
||||
}.redacted(reason: .placeholder)
|
||||
} else {
|
||||
ForEach(viewModel.fetcher.items) { item in
|
||||
GridCardNavigationLink(
|
||||
item: item,
|
||||
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
|
||||
isContextMenuOpen: $isContextMenuOpen,
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
|
@ -949,3 +1008,21 @@ struct LinkDestination: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fakeLibraryItems(dataService: DataService) -> [Models.LibraryItem] {
|
||||
let temp = Models.LibraryItem(context: dataService.viewContext)
|
||||
temp.id = UUID().uuidString
|
||||
temp.wordsCount = 100
|
||||
temp.author = "the author"
|
||||
temp.siteName = "omnivore dot app"
|
||||
temp.title = "This is a temporary title for a fake item"
|
||||
temp.highlights = []
|
||||
temp.imageURLString = "https://localhost/"
|
||||
return Array(
|
||||
repeatElement(temp, count: 20)
|
||||
.map { item in
|
||||
item.id = UUID().uuidString
|
||||
return item
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import Views
|
|||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
|
||||
|
||||
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
|
||||
|
||||
@Published var appliedFilter: InternalFilter? {
|
||||
didSet {
|
||||
let filterKey = UserDefaults.standard.string(forKey: "lastSelected-\(folder)-filter") ?? folder
|
||||
|
|
@ -152,10 +154,9 @@ import Views
|
|||
|
||||
func loadItems(dataService: DataService, isRefresh: Bool) async {
|
||||
isLoading = true
|
||||
showLoadingBar = true
|
||||
showLoadingBar = isRefresh
|
||||
|
||||
await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
|
||||
isLoading = false
|
||||
|
|
@ -164,12 +165,10 @@ import Views
|
|||
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
isLoading = true
|
||||
showLoadingBar = true
|
||||
|
||||
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
|
||||
isLoading = false
|
||||
showLoadingBar = false
|
||||
}
|
||||
|
||||
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [Models.LibraryItem] {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
|
||||
import Introspect
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class LibraryAddFeedViewModel: NSObject, ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var showErrorMessage: Bool = false
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
func addLink(dataService: DataService, newLinkURL: String, dismiss: DismissAction) {
|
||||
isLoading = true
|
||||
Task {
|
||||
if URL(string: newLinkURL) == nil {
|
||||
error("Invalid link")
|
||||
} else {
|
||||
let result = try? await dataService.saveURL(id: UUID().uuidString, url: newLinkURL)
|
||||
if result == nil {
|
||||
error("Error adding link")
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func error(_ msg: String) {
|
||||
errorMessage = msg
|
||||
showErrorMessage = true
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryAddFeedView: View {
|
||||
@StateObject var viewModel = LibraryAddFeedViewModel()
|
||||
|
||||
@State var newLinkURL: String = ""
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case addLinkEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
.navigationTitle("Add Link")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
#else
|
||||
innerBody
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding()
|
||||
#endif
|
||||
.onAppear {
|
||||
focusedField = .addLinkEditor
|
||||
}
|
||||
.navigationTitle("Add Link")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
dismissButton
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
viewModel.isLoading ? AnyView(ProgressView()) : AnyView(addButton)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alert(viewModel.errorMessage,
|
||||
isPresented: $viewModel.showErrorMessage) {
|
||||
Button(LocalText.genericOk, role: .cancel) { viewModel.showErrorMessage = false }
|
||||
}
|
||||
}
|
||||
|
||||
var cancelButton: some View {
|
||||
Button(
|
||||
action: { dismiss() },
|
||||
label: { Text(LocalText.cancelGeneric).foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
|
||||
var pasteboardString: String? {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.url?.absoluteString
|
||||
#else
|
||||
NSPasteboard.general.string(forType: NSPasteboard.PasteboardType.URL)
|
||||
#endif
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
Group {
|
||||
TextField("Add Link", text: $newLinkURL)
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
.autocorrectionDisabled(true)
|
||||
.textFieldStyle(StandardTextFieldStyle())
|
||||
.focused($focusedField, equals: .addLinkEditor)
|
||||
|
||||
Button(action: {
|
||||
if let url = pasteboardString {
|
||||
newLinkURL = url
|
||||
} else {
|
||||
viewModel.error("No URL on pasteboard")
|
||||
}
|
||||
}, label: {
|
||||
Text("Get from pasteboard")
|
||||
})
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
HStack {
|
||||
cancelButton
|
||||
Spacer()
|
||||
addButton
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var addButton: some View {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
|
||||
},
|
||||
label: { Text("Add").bold() }
|
||||
)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.onSubmit {
|
||||
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
Button(
|
||||
action: { dismiss() },
|
||||
label: { Text(LocalText.genericClose) }
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
|
|
@ -33,11 +33,11 @@
|
|||
performTypeahead(searchTerm)
|
||||
}
|
||||
|
||||
func performSearch(_: String) {
|
||||
// let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines)
|
||||
// viewModel.saveRecentSearch(dataService: dataService, searchTerm: term)
|
||||
// recents = viewModel.recentSearches(dataService: dataService)
|
||||
// homeFeedViewModel.searchTerm = term
|
||||
func performSearch(_ searchTerm: String) {
|
||||
let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines)
|
||||
viewModel.saveRecentSearch(dataService: dataService, searchTerm: term)
|
||||
recents = viewModel.recentSearches(dataService: dataService)
|
||||
homeFeedViewModel.searchTerm = term
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ struct LibraryTabView: View {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
|
||||
@AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox"
|
||||
|
||||
@State var showExpandedAudioPlayer = false
|
||||
|
|
@ -53,11 +54,13 @@ struct LibraryTabView: View {
|
|||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationView {
|
||||
HomeFeedContainerView(viewModel: followingViewModel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("following")
|
||||
if !hideFollowingTab {
|
||||
NavigationView {
|
||||
HomeFeedContainerView(viewModel: followingViewModel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("following")
|
||||
}
|
||||
|
||||
NavigationView {
|
||||
HomeFeedContainerView(viewModel: libraryViewModel)
|
||||
|
|
@ -80,7 +83,7 @@ struct LibraryTabView: View {
|
|||
.frame(height: 1)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
CustomTabBar(selectedTab: $selectedTab)
|
||||
CustomTabBar(selectedTab: $selectedTab, hideFollowingTab: hideFollowingTab)
|
||||
.padding(0)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Views
|
|||
@Published var networkError = false
|
||||
@Published var libraryFilters = [InternalFilter]()
|
||||
|
||||
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
|
||||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
|
||||
func loadFilters(dataService: DataService) async {
|
||||
|
|
@ -50,7 +51,8 @@ struct FiltersView: View {
|
|||
private var innerBody: some View {
|
||||
List {
|
||||
Section {
|
||||
Toggle("Hide Feature Section", isOn: $viewModel.hideFeatureSection)
|
||||
Toggle("Hide following tab", isOn: $viewModel.hideFollowingTab)
|
||||
Toggle("Hide feature section", isOn: $viewModel.hideFeatureSection)
|
||||
}
|
||||
|
||||
Section(header: Text("Saved Searches")) {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ import SwiftUI
|
|||
|
||||
struct CustomTabBar: View {
|
||||
@Binding var selectedTab: String
|
||||
let hideFollowingTab: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
TabBarButton(key: "following", image: Image.tabFollowing, selectedTab: $selectedTab)
|
||||
if !hideFollowingTab {
|
||||
TabBarButton(key: "following", image: Image.tabFollowing, selectedTab: $selectedTab)
|
||||
}
|
||||
TabBarButton(key: "inbox", image: Image.tabLibrary, selectedTab: $selectedTab)
|
||||
TabBarButton(key: "profile", image: Image.tabProfile, selectedTab: $selectedTab)
|
||||
}
|
||||
|
|
|
|||
31
apple/OmnivoreKit/Sources/Utils/FirstFewWords.swift
Normal file
31
apple/OmnivoreKit/Sources/Utils/FirstFewWords.swift
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 12/8/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import NaturalLanguage
|
||||
|
||||
public func extractFirstFewWords(_ title: String) -> String {
|
||||
let languageRecognizer = NLLanguageRecognizer()
|
||||
|
||||
languageRecognizer.processString(title)
|
||||
let language = languageRecognizer.dominantLanguage ?? NLLanguage.english
|
||||
|
||||
let tokenizer = NLTokenizer(unit: .word)
|
||||
tokenizer.setLanguage(language)
|
||||
tokenizer.string = title
|
||||
|
||||
var words: [String] = []
|
||||
tokenizer.enumerateTokens(in: title.startIndex ..< title.endIndex) { range, _ in
|
||||
let word = String(title[range])
|
||||
words.append(word)
|
||||
return true
|
||||
}
|
||||
|
||||
print("WORDS: ", words)
|
||||
let truncatedTitle = words.prefix(2).joined(separator: " ")
|
||||
return truncatedTitle
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ public extension Color {
|
|||
static var themeSolidBackground: Color { Color("_themeSolidBackground", bundle: .module) }
|
||||
static var thBorderColor: Color { Color("thBorderColor", bundle: .module) }
|
||||
static var thLibrarySeparator: Color { Color("thLibrarySeparator", bundle: .module) }
|
||||
static var thLightWhiteGrey: Color { Color("_themeLightWhiteGrey", bundle: .module) }
|
||||
|
||||
static var thFeatureSeparator: Color { Color("featureSeparator", bundle: .module) }
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xED",
|
||||
"green" : "0xED",
|
||||
"red" : "0xED"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xED",
|
||||
"green" : "0xED",
|
||||
"red" : "0xED"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -107,18 +107,26 @@ public struct GridCard: View {
|
|||
.cornerRadius(5)
|
||||
}
|
||||
|
||||
var fallbackFont: Font {
|
||||
if let uifont = UIFont(name: "Futura Bold", size: 16) {
|
||||
return Font(uifont)
|
||||
}
|
||||
return Font.system(size: 16)
|
||||
}
|
||||
|
||||
var fallbackImage: some View {
|
||||
GeometryReader { geo in
|
||||
HStack {
|
||||
Text(item.unwrappedTitle.prefix(1))
|
||||
.font(Font.system(size: 128, weight: .bold))
|
||||
.offset(CGSize(width: -48, height: 12))
|
||||
.frame(alignment: .bottomLeading)
|
||||
.foregroundColor(Gradient.randomColor(str: item.unwrappedTitle, offset: 1))
|
||||
Text(item.unwrappedTitle)
|
||||
.font(fallbackFont)
|
||||
.frame(alignment: .center)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.padding(10)
|
||||
.foregroundColor(Color.themeMiddleGray)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Gradient.randomColor(str: item.unwrappedTitle, offset: 0))
|
||||
.background(LinearGradient(gradient: Gradient(fromStr: item.unwrappedTitle)!, startPoint: .top, endPoint: .bottom))
|
||||
.background(Color.thLightWhiteGrey)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,17 +66,25 @@ public struct LibraryFeatureCard: View {
|
|||
.cornerRadius(5)
|
||||
}
|
||||
|
||||
var fallbackFont: Font {
|
||||
if let uifont = UIFont(name: "Futura Bold", size: 16) {
|
||||
return Font(uifont)
|
||||
}
|
||||
return Font.system(size: 16)
|
||||
}
|
||||
|
||||
var fallbackImage: some View {
|
||||
HStack {
|
||||
Text(item.unwrappedTitle.prefix(1))
|
||||
.font(Font.system(size: 128, weight: .bold))
|
||||
.offset(CGSize(width: -48, height: 12))
|
||||
.frame(alignment: .bottomLeading)
|
||||
.foregroundColor(Gradient.randomColor(str: item.unwrappedTitle, offset: 1))
|
||||
Text(item.unwrappedTitle)
|
||||
.font(fallbackFont)
|
||||
.frame(alignment: .center)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.padding(10)
|
||||
.foregroundColor(Color.themeMiddleGray)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Gradient.randomColor(str: item.unwrappedTitle, offset: 0))
|
||||
.background(LinearGradient(gradient: Gradient(fromStr: item.unwrappedTitle)!, startPoint: .top, endPoint: .bottom))
|
||||
.background(Color.thLightWhiteGrey)
|
||||
.frame(width: 146, height: 90)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public enum WebFont: String, CaseIterable {
|
|||
case sourceSansPro = "SourceSansPro"
|
||||
case lexend = "Lexend"
|
||||
case IBMPlexSans
|
||||
case literata = "Literata"
|
||||
case fraunces = "Fraunces"
|
||||
|
||||
static var sorted: [WebFont] {
|
||||
allCases.sorted { left, right in
|
||||
|
|
@ -50,7 +52,9 @@ public enum WebFont: String, CaseIterable {
|
|||
.montserrat,
|
||||
.newsreader,
|
||||
.lxgWWenKai,
|
||||
.lexend:
|
||||
.lexend,
|
||||
.literata,
|
||||
.fraunces:
|
||||
return rawValue
|
||||
case .atkinsonHyperlegible:
|
||||
return "Atkinson Hyperlegible"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ public extension Image {
|
|||
static var readerSettings: Image { Image("reader-settings", bundle: .module) }
|
||||
static var utilityMenu: Image { Image("utility-menu", bundle: .module) }
|
||||
|
||||
static var addLink: Image { Image("add-link", bundle: .module) }
|
||||
static var selectMultiple: Image { Image("select-multiple", bundle: .module) }
|
||||
static var magnifyingGlass: Image { Image("magnifying-glass", bundle: .module) }
|
||||
|
||||
static var archive: Image { Image("archive", bundle: .module) }
|
||||
static var unarchive: Image { Image("unarchive", bundle: .module) }
|
||||
static var remove: Image { Image("remove", bundle: .module) }
|
||||
|
|
|
|||
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/add-link.imageset/Contents.json
vendored
Normal file
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/add-link.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "add-link.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
11
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/add-link.imageset/add-link.svg
vendored
Normal file
11
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/add-link.imageset/add-link.svg
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="26" height="25" viewBox="0 0 26 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6802_32635)">
|
||||
<path d="M13.3398 5.20801V19.7913" stroke="#EDEDED" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.0481 12.5H20.6314" stroke="#EDEDED" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6802_32635">
|
||||
<rect width="25" height="25" fill="white" transform="translate(0.839844)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 519 B |
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/magnifying-glass.imageset/Contents.json
vendored
Normal file
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/magnifying-glass.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "magnifying-glass.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="26" height="25" viewBox="0 0 26 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6802_32619)">
|
||||
<path d="M5.08984 10.6667C5.08984 11.5093 5.25582 12.3437 5.57828 13.1222C5.90075 13.9007 6.3734 14.6081 6.96924 15.2039C7.56508 15.7998 8.27245 16.2724 9.05096 16.5949C9.82946 16.9174 10.6639 17.0833 11.5065 17.0833C12.3492 17.0833 13.1836 16.9174 13.9621 16.5949C14.7406 16.2724 15.4479 15.7998 16.0438 15.2039C16.6396 14.6081 17.1123 13.9007 17.4347 13.1222C17.7572 12.3437 17.9232 11.5093 17.9232 10.6667C17.9232 9.82402 17.7572 8.98962 17.4347 8.21111C17.1123 7.43261 16.6396 6.72524 16.0438 6.1294C15.4479 5.53356 14.7406 5.06091 13.9621 4.73844C13.1836 4.41597 12.3492 4.25 11.5065 4.25C10.6639 4.25 9.82946 4.41597 9.05096 4.73844C8.27245 5.06091 7.56508 5.53356 6.96924 6.1294C6.3734 6.72524 5.90075 7.43261 5.57828 8.21111C5.25582 8.98962 5.08984 9.82402 5.08984 10.6667Z" stroke="#EDEDED" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.5898 20.75L16.0898 15.25" stroke="#EDEDED" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6802_32619">
|
||||
<rect width="25" height="25" fill="white" transform="translate(0.839844)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/select-multiple.imageset/Contents.json
vendored
Normal file
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/select-multiple.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "multiple-select.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6802_32631)">
|
||||
<path d="M10.3398 12.2402L13.3398 15.2402L21.3398 7.24023" stroke="#EDEDED" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.3398 13.2402V19.2402C21.3398 19.7707 21.1291 20.2794 20.7541 20.6544C20.379 21.0295 19.8703 21.2402 19.3398 21.2402H7.33984C6.80941 21.2402 6.3007 21.0295 5.92563 20.6544C5.55056 20.2794 5.33984 19.7707 5.33984 19.2402V7.24023C5.33984 6.7098 5.55056 6.20109 5.92563 5.82602C6.3007 5.45095 6.80941 5.24023 7.33984 5.24023H16.3398" stroke="#EDEDED" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6802_32631">
|
||||
<rect width="25" height="25" fill="white" transform="translate(0.839844 0.740234)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 864 B |
File diff suppressed because one or more lines are too long
|
|
@ -2301,7 +2301,6 @@ export enum ScanFeedsErrorCode {
|
|||
|
||||
export type ScanFeedsInput = {
|
||||
opml?: InputMaybe<Scalars['String']>;
|
||||
type: ScanFeedsType;
|
||||
url?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
|
|
@ -2312,11 +2311,6 @@ export type ScanFeedsSuccess = {
|
|||
feeds: Array<Feed>;
|
||||
};
|
||||
|
||||
export enum ScanFeedsType {
|
||||
Html = 'HTML',
|
||||
Opml = 'OPML'
|
||||
}
|
||||
|
||||
export type SearchError = {
|
||||
__typename?: 'SearchError';
|
||||
errorCodes: Array<SearchErrorCode>;
|
||||
|
|
@ -3790,7 +3784,6 @@ export type ResolversTypes = {
|
|||
ScanFeedsInput: ScanFeedsInput;
|
||||
ScanFeedsResult: ResolversTypes['ScanFeedsError'] | ResolversTypes['ScanFeedsSuccess'];
|
||||
ScanFeedsSuccess: ResolverTypeWrapper<ScanFeedsSuccess>;
|
||||
ScanFeedsType: ScanFeedsType;
|
||||
SearchError: ResolverTypeWrapper<SearchError>;
|
||||
SearchErrorCode: SearchErrorCode;
|
||||
SearchItem: ResolverTypeWrapper<SearchItem>;
|
||||
|
|
|
|||
|
|
@ -1751,7 +1751,6 @@ enum ScanFeedsErrorCode {
|
|||
|
||||
input ScanFeedsInput {
|
||||
opml: String
|
||||
type: ScanFeedsType!
|
||||
url: String
|
||||
}
|
||||
|
||||
|
|
@ -1761,11 +1760,6 @@ type ScanFeedsSuccess {
|
|||
feeds: [Feed!]!
|
||||
}
|
||||
|
||||
enum ScanFeedsType {
|
||||
HTML
|
||||
OPML
|
||||
}
|
||||
|
||||
type SearchError {
|
||||
errorCodes: [SearchErrorCode!]!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -517,4 +517,5 @@ export const functionResolvers = {
|
|||
...resultResolveTypeResolver('SetFavoriteArticle'),
|
||||
...resultResolveTypeResolver('UpdateSubscription'),
|
||||
...resultResolveTypeResolver('UpdateEmail'),
|
||||
...resultResolveTypeResolver('ScanFeeds'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
ScanFeedsError,
|
||||
ScanFeedsErrorCode,
|
||||
ScanFeedsSuccess,
|
||||
ScanFeedsType,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
SubscribeError,
|
||||
|
|
@ -41,8 +40,12 @@ import { unsubscribe } from '../../services/subscriptions'
|
|||
import { Merge } from '../../util'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { enqueueRssFeedFetch } from '../../utils/createTask'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { parseFeed, parseOpml } from '../../utils/parser'
|
||||
import {
|
||||
authorized,
|
||||
getAbsoluteUrl,
|
||||
keysToCamelCase,
|
||||
} from '../../utils/helpers'
|
||||
import { parseFeed, parseOpml, RSS_PARSER_CONFIG } from '../../utils/parser'
|
||||
|
||||
type PartialSubscription = Omit<Subscription, 'newsletterEmail'>
|
||||
|
||||
|
|
@ -223,7 +226,7 @@ export const subscribeResolver = authorized<
|
|||
}
|
||||
|
||||
// create new rss subscription
|
||||
const MAX_RSS_SUBSCRIPTIONS = 150
|
||||
const MAX_RSS_SUBSCRIPTIONS = env.subscription.feed.max
|
||||
// validate rss feed
|
||||
const feed = await parseFeed(input.url)
|
||||
if (!feed) {
|
||||
|
|
@ -232,7 +235,7 @@ export const subscribeResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
// limit number of rss subscriptions to 150
|
||||
// limit number of rss subscriptions to max
|
||||
const results = (await getRepository(Subscription).query(
|
||||
`insert into omnivore.subscriptions (name, url, description, type, user_id, icon, auto_add_to_library, is_private)
|
||||
select $1, $2, $3, $4, $5, $6, $7, $8 from omnivore.subscriptions
|
||||
|
|
@ -250,7 +253,7 @@ export const subscribeResolver = authorized<
|
|||
input.isPrivate ?? null,
|
||||
MAX_RSS_SUBSCRIPTIONS,
|
||||
]
|
||||
)) as Subscription[]
|
||||
)) as any[]
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
|
|
@ -258,12 +261,13 @@ export const subscribeResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
const newSubscription = results[0]
|
||||
// convert to camel case
|
||||
const newSubscription = keysToCamelCase(results[0]) as Subscription
|
||||
|
||||
// create a cloud task to fetch rss feed item for the new subscription
|
||||
await enqueueRssFeedFetch({
|
||||
userIds: [uid],
|
||||
url: input.url,
|
||||
url: feed.url,
|
||||
subscriptionIds: [newSubscription.id],
|
||||
scheduledDates: [new Date()], // fetch immediately
|
||||
fetchedDates: [null],
|
||||
|
|
@ -399,22 +403,17 @@ export const scanFeedsResolver = authorized<
|
|||
ScanFeedsSuccess,
|
||||
ScanFeedsError,
|
||||
QueryScanFeedsArgs
|
||||
>(async (_, { input: { type, opml, url } }, { log, uid }) => {
|
||||
>(async (_, { input: { opml, url } }, { log, uid }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'scan_feeds',
|
||||
properties: {
|
||||
type,
|
||||
opml,
|
||||
url,
|
||||
},
|
||||
})
|
||||
|
||||
if (type === ScanFeedsType.Opml) {
|
||||
if (!opml) {
|
||||
return {
|
||||
errorCodes: [ScanFeedsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
if (opml) {
|
||||
// parse opml
|
||||
const feeds = parseOpml(opml)
|
||||
if (!feeds) {
|
||||
|
|
@ -424,7 +423,6 @@ export const scanFeedsResolver = authorized<
|
|||
}
|
||||
|
||||
return {
|
||||
__typename: 'ScanFeedsSuccess',
|
||||
feeds: feeds.map((feed) => ({
|
||||
url: feed.url,
|
||||
title: feed.title,
|
||||
|
|
@ -434,37 +432,60 @@ export const scanFeedsResolver = authorized<
|
|||
}
|
||||
|
||||
if (!url) {
|
||||
log.error('Missing opml and url')
|
||||
|
||||
return {
|
||||
errorCodes: [ScanFeedsErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// fetch HTML and parse feeds
|
||||
const response = await axios.get(url, {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Accept: 'text/html',
|
||||
},
|
||||
})
|
||||
const html = response.data as string
|
||||
const dom = parseHTML(html).document
|
||||
const links = dom.querySelectorAll('link[type="application/rss+xml"]')
|
||||
const feeds = Array.from(links)
|
||||
.map((link) => ({
|
||||
url: link.getAttribute('href') || '',
|
||||
title: link.getAttribute('title') || '',
|
||||
type: 'rss',
|
||||
}))
|
||||
.filter((feed) => feed.url)
|
||||
// fetch page content and parse feeds
|
||||
const response = await axios.get(url, RSS_PARSER_CONFIG)
|
||||
const content = response.data as string
|
||||
// check if the content is html or xml
|
||||
const contentType = response.headers['Content-Type']
|
||||
const isHtml = contentType?.includes('text/html')
|
||||
if (isHtml) {
|
||||
// this is an html page, parse rss feed links
|
||||
const dom = parseHTML(content).document
|
||||
// type is application/rss+xml or application/atom+xml
|
||||
const links = dom.querySelectorAll(
|
||||
'link[type="application/rss+xml"], link[type="application/atom+xml"]'
|
||||
)
|
||||
|
||||
const feeds = Array.from(links)
|
||||
.map((link) => {
|
||||
const href = link.getAttribute('href') || ''
|
||||
const feedUrl = getAbsoluteUrl(href, url)
|
||||
|
||||
return {
|
||||
url: feedUrl,
|
||||
title: link.getAttribute('title') || '',
|
||||
type: 'rss',
|
||||
}
|
||||
})
|
||||
.filter((feed) => feed.url)
|
||||
|
||||
return {
|
||||
feeds,
|
||||
}
|
||||
}
|
||||
|
||||
// this is the url to an RSS feed
|
||||
const feed = await parseFeed(url, content)
|
||||
if (!feed) {
|
||||
log.error('Failed to parse RSS feed')
|
||||
return {
|
||||
feeds: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
__typename: 'ScanFeedsSuccess',
|
||||
feeds,
|
||||
feeds: [feed],
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error scanning HTML', error)
|
||||
log.error('Error scanning URL', error)
|
||||
|
||||
return {
|
||||
errorCodes: [ScanFeedsErrorCode.BadRequest],
|
||||
|
|
|
|||
|
|
@ -2686,16 +2686,10 @@ const schema = gql`
|
|||
}
|
||||
|
||||
input ScanFeedsInput {
|
||||
type: ScanFeedsType!
|
||||
url: String
|
||||
opml: String
|
||||
}
|
||||
|
||||
enum ScanFeedsType {
|
||||
OPML
|
||||
HTML
|
||||
}
|
||||
|
||||
union ScanFeedsResult = ScanFeedsSuccess | ScanFeedsError
|
||||
|
||||
type ScanFeedsSuccess {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LiqeQuery } from '@omnivore/liqe'
|
||||
import { ExpressionToken, LiqeQuery } from '@omnivore/liqe'
|
||||
import { DateTime } from 'luxon'
|
||||
import { DeepPartial, ObjectLiteral } from 'typeorm'
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
||||
|
|
@ -130,7 +130,8 @@ export const sortParamsToSort = (
|
|||
}
|
||||
|
||||
const getColumnName = (field: string) => {
|
||||
switch (field) {
|
||||
const lowerCaseField = field.toLowerCase()
|
||||
switch (lowerCaseField) {
|
||||
case 'language':
|
||||
return 'item_language'
|
||||
case 'subscription':
|
||||
|
|
@ -138,17 +139,17 @@ const getColumnName = (field: string) => {
|
|||
return 'subscription'
|
||||
case 'site':
|
||||
return 'site_name'
|
||||
case 'wordsCount':
|
||||
case 'wordscount':
|
||||
return 'word_count'
|
||||
case 'readPosition':
|
||||
case 'readposition':
|
||||
return 'reading_progress_bottom_percent'
|
||||
case 'saved':
|
||||
case 'read':
|
||||
case 'updated':
|
||||
case 'published':
|
||||
return `${field}_at`
|
||||
return `${lowerCaseField}_at`
|
||||
default:
|
||||
return field
|
||||
return lowerCaseField
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,50 +168,58 @@ export const buildQuery = (
|
|||
return query
|
||||
}
|
||||
|
||||
const serializeImplicitField = (
|
||||
expression: ExpressionToken
|
||||
): string | null => {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
|
||||
if (value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const param = `implicit_field_${parameters.length}`
|
||||
const alias = `rank_${parameters.length}`
|
||||
selects.push({
|
||||
column: `ts_rank_cd(library_item.search_tsv, websearch_to_tsquery('english', :${param}))`,
|
||||
alias,
|
||||
})
|
||||
|
||||
orders.push({ by: alias, order: SortOrder.DESCENDING })
|
||||
|
||||
return escapeQueryWithParameters(
|
||||
`websearch_to_tsquery('english', :${param}) @@ library_item.search_tsv`,
|
||||
{ [param]: value }
|
||||
)
|
||||
}
|
||||
|
||||
const serializeTagExpression = (ast: LiqeQuery): string | null => {
|
||||
if (ast.type !== 'Tag') {
|
||||
throw new Error('Expected a tag expression.')
|
||||
throw new Error('Expected a tag expression')
|
||||
}
|
||||
|
||||
const { field, expression } = ast
|
||||
|
||||
if (field.type === 'ImplicitField') {
|
||||
return serializeImplicitField(expression)
|
||||
} else {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
|
||||
if (value === undefined || value === '') {
|
||||
// ignore empty values
|
||||
return null
|
||||
}
|
||||
|
||||
const param = `implicit_field_${parameters.length}`
|
||||
const alias = `rank_${parameters.length}`
|
||||
selects.push({
|
||||
column: `ts_rank_cd(library_item.search_tsv, websearch_to_tsquery('english', :${param}))`,
|
||||
alias,
|
||||
})
|
||||
const value = expression.value?.toString()
|
||||
if (!value) {
|
||||
// ignore empty values
|
||||
return null
|
||||
}
|
||||
|
||||
orders.push({ by: alias, order: SortOrder.DESCENDING })
|
||||
|
||||
return escapeQueryWithParameters(
|
||||
`websearch_to_tsquery('english', :${param}) @@ library_item.search_tsv`,
|
||||
{ [param]: value }
|
||||
)
|
||||
} else {
|
||||
switch (field.name) {
|
||||
switch (field.name.toLowerCase()) {
|
||||
case 'in': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const folder = expression.value?.toString()
|
||||
if (!folder) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
switch (folder) {
|
||||
switch (value.toLowerCase()) {
|
||||
case InFilter.ALL:
|
||||
return null
|
||||
case InFilter.ARCHIVE:
|
||||
|
|
@ -224,7 +233,7 @@ export const buildQuery = (
|
|||
const param = `folder_${parameters.length}`
|
||||
const folderSql = escapeQueryWithParameters(
|
||||
`library_item.folder = :${param}`,
|
||||
{ [param]: folder }
|
||||
{ [param]: value }
|
||||
)
|
||||
sql = `(${sql} AND ${folderSql})`
|
||||
}
|
||||
|
|
@ -235,16 +244,7 @@ export const buildQuery = (
|
|||
}
|
||||
|
||||
case 'is': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
switch (value.toLowerCase()) {
|
||||
case ReadFilter.READ:
|
||||
return 'library_item.reading_progress_bottom_percent > 98'
|
||||
case ReadFilter.READING:
|
||||
|
|
@ -256,15 +256,6 @@ export const buildQuery = (
|
|||
}
|
||||
}
|
||||
case 'type': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
const param = `type_${parameters.length}`
|
||||
|
||||
return escapeQueryWithParameters(
|
||||
|
|
@ -275,16 +266,7 @@ export const buildQuery = (
|
|||
)
|
||||
}
|
||||
case 'label': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()?.toLowerCase()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
const labels = value.split(',')
|
||||
const labels = value.toLowerCase().split(',')
|
||||
return (
|
||||
labels
|
||||
.map((label) => {
|
||||
|
|
@ -313,18 +295,14 @@ export const buildQuery = (
|
|||
)
|
||||
}
|
||||
case 'sort': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
const [sort, sortOrder] = value.split('-')
|
||||
if (sort.toLowerCase() === 'score') {
|
||||
// score is not a column and is handled separately
|
||||
return null
|
||||
}
|
||||
|
||||
const order =
|
||||
sortOrder?.toUpperCase() === 'ASC'
|
||||
sortOrder?.toLowerCase() === 'asc'
|
||||
? SortOrder.ASCENDING
|
||||
: SortOrder.DESCENDING
|
||||
|
||||
|
|
@ -333,16 +311,7 @@ export const buildQuery = (
|
|||
return null
|
||||
}
|
||||
case 'has': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
switch (value.toLowerCase()) {
|
||||
case HasFilter.HIGHLIGHTS:
|
||||
return "library_item.highlight_annotations <> '{}'"
|
||||
case HasFilter.LABELS:
|
||||
|
|
@ -357,19 +326,10 @@ export const buildQuery = (
|
|||
case 'read':
|
||||
case 'updated':
|
||||
case 'published': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const date = expression.value?.toString()
|
||||
if (!date) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
let startDate: Date | undefined
|
||||
let endDate: Date | undefined
|
||||
// check for special date filters
|
||||
switch (date.toLowerCase()) {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'today':
|
||||
startDate = DateTime.local().startOf('day').toJSDate()
|
||||
break
|
||||
|
|
@ -387,19 +347,19 @@ export const buildQuery = (
|
|||
break
|
||||
default: {
|
||||
// check for date ranges
|
||||
const [start, end] = date.split('..')
|
||||
const [start, end] = value.split('..')
|
||||
// validate date
|
||||
if (start && start !== '*') {
|
||||
startDate = new Date(start)
|
||||
if (isNaN(startDate.getTime())) {
|
||||
throw new Error('Invalid start date.')
|
||||
throw new Error('Invalid start date')
|
||||
}
|
||||
}
|
||||
|
||||
if (end && end !== '*') {
|
||||
endDate = new Date(end)
|
||||
if (isNaN(endDate.getTime())) {
|
||||
throw new Error('Invalid end date.')
|
||||
throw new Error('Invalid end date')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -420,15 +380,6 @@ export const buildQuery = (
|
|||
case 'subscription':
|
||||
case 'rss':
|
||||
case 'language': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
const columnName = getColumnName(field.name)
|
||||
const param = `term_${field.name}_${parameters.length}`
|
||||
|
||||
|
|
@ -445,16 +396,6 @@ export const buildQuery = (
|
|||
case 'description':
|
||||
case 'note':
|
||||
case 'site': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
// normalize the term to lower case
|
||||
const value = expression.value?.toString()?.toLowerCase()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
const columnName = getColumnName(field.name)
|
||||
const param = `match_${field.name}_${parameters.length}`
|
||||
const wildcardParam = `match_${field.name}_wildcard_${parameters.length}`
|
||||
|
|
@ -468,13 +409,9 @@ export const buildQuery = (
|
|||
)
|
||||
}
|
||||
case 'includes': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const ids = expression.value?.toString()?.split(',')
|
||||
const ids = value.split(',')
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new Error('Expected a value.')
|
||||
throw new Error('Expected ids')
|
||||
}
|
||||
|
||||
const param = `includes_${parameters.length}`
|
||||
|
|
@ -483,16 +420,7 @@ export const buildQuery = (
|
|||
[param]: ids,
|
||||
})
|
||||
}
|
||||
case 'recommendedBy': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
case 'recommendedby': {
|
||||
const param = `recommendedBy_${parameters.length}`
|
||||
if (value === '*') {
|
||||
// select all if * is provided
|
||||
|
|
@ -507,17 +435,8 @@ export const buildQuery = (
|
|||
)
|
||||
}
|
||||
case 'no': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
const value = expression.value?.toString()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
let column = ''
|
||||
switch (value) {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'highlight':
|
||||
column = 'highlight_annotations'
|
||||
break
|
||||
|
|
@ -538,41 +457,33 @@ export const buildQuery = (
|
|||
case 'event':
|
||||
// mode is ignored and used only by the frontend
|
||||
return null
|
||||
case 'readPosition':
|
||||
case 'wordsCount': {
|
||||
if (expression.type !== 'LiteralExpression') {
|
||||
throw new Error('Expected a literal expression.')
|
||||
}
|
||||
|
||||
let value = expression.value?.toString()
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
|
||||
case 'readposition':
|
||||
case 'wordscount': {
|
||||
const column = getColumnName(field.name)
|
||||
|
||||
const operatorRegex = /([<>]=?)/
|
||||
const operator = value.match(operatorRegex)?.[0]
|
||||
if (!operator) {
|
||||
throw new Error('Expected a value.')
|
||||
throw new Error('Expected operator')
|
||||
}
|
||||
|
||||
value = value.replace(operatorRegex, '')
|
||||
if (!value) {
|
||||
throw new Error('Expected a value.')
|
||||
}
|
||||
const newValue = value.replace(operatorRegex, '')
|
||||
|
||||
const param = `range_${field.name}_${parameters.length}`
|
||||
|
||||
return escapeQueryWithParameters(
|
||||
`library_item.${column} ${operator} :${param}`,
|
||||
{
|
||||
[param]: parseInt(value, 10),
|
||||
[param]: parseInt(newValue, 10),
|
||||
}
|
||||
)
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unexpected keyword: ${field.name}`)
|
||||
// treat unknown fields as implicit fields
|
||||
return serializeImplicitField({
|
||||
...expression,
|
||||
value: `${field.name}:${value}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -589,7 +500,7 @@ export const buildQuery = (
|
|||
} else if (ast.operator.operator === 'OR') {
|
||||
operator = 'OR'
|
||||
} else {
|
||||
throw new Error('Unexpected operator.')
|
||||
throw new Error('Unexpected operator')
|
||||
}
|
||||
|
||||
const left = serialize(ast.left)
|
||||
|
|
@ -630,7 +541,7 @@ export const buildQuery = (
|
|||
return `(${serialized})`
|
||||
}
|
||||
|
||||
throw new Error('Missing AST type.')
|
||||
return null
|
||||
}
|
||||
|
||||
return serialize(searchQuery)
|
||||
|
|
|
|||
|
|
@ -36,30 +36,31 @@ export const addRecommendation = async (
|
|||
thumbnail: item.thumbnail,
|
||||
uploadFile: item.uploadFile,
|
||||
wordCount: item.wordCount,
|
||||
publishedAt: item.publishedAt,
|
||||
}
|
||||
|
||||
recommendedItem = await createLibraryItem(newItem, userId)
|
||||
}
|
||||
|
||||
const highlights = item.highlights
|
||||
?.filter((highlight) => highlightIds?.includes(highlight.id))
|
||||
.map((highlight) => ({
|
||||
shortId: nanoid(8),
|
||||
createdAt: new Date(),
|
||||
libraryItem: { id: recommendedItem?.id },
|
||||
user: { id: userId },
|
||||
quote: highlight.quote,
|
||||
annotation: highlight.annotation,
|
||||
prefix: highlight.prefix,
|
||||
suffix: highlight.suffix,
|
||||
patch: highlight.patch,
|
||||
updatedAt: new Date(),
|
||||
sharedAt: new Date(),
|
||||
html: highlight.html,
|
||||
color: highlight.color,
|
||||
}))
|
||||
if (highlights) {
|
||||
await createHighlights(highlights, userId)
|
||||
const highlights = item.highlights
|
||||
?.filter((highlight) => highlightIds?.includes(highlight.id))
|
||||
.map((highlight) => ({
|
||||
shortId: nanoid(8),
|
||||
createdAt: new Date(),
|
||||
libraryItem: { id: recommendedItem?.id },
|
||||
user: { id: userId },
|
||||
quote: highlight.quote,
|
||||
annotation: highlight.annotation,
|
||||
prefix: highlight.prefix,
|
||||
suffix: highlight.suffix,
|
||||
patch: highlight.patch,
|
||||
updatedAt: new Date(),
|
||||
sharedAt: new Date(),
|
||||
html: highlight.html,
|
||||
color: highlight.color,
|
||||
}))
|
||||
if (highlights) {
|
||||
await createHighlights(highlights, userId)
|
||||
}
|
||||
}
|
||||
|
||||
await createRecommendation(
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ interface BackendEnv {
|
|||
pocket: {
|
||||
consumerKey: string
|
||||
}
|
||||
subscription: {
|
||||
feed: {
|
||||
max: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***
|
||||
|
|
@ -167,6 +172,7 @@ const nullableEnvVars = [
|
|||
'TRUST_PROXY',
|
||||
'INTEGRATION_EXPORTER_URL',
|
||||
'INTEGRATION_IMPORTER_URL',
|
||||
'SUBSCRIPTION_FEED_MAX',
|
||||
] // Allow some vars to be null/empty
|
||||
|
||||
/* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */
|
||||
|
|
@ -303,6 +309,14 @@ export function getEnv(): BackendEnv {
|
|||
consumerKey: parse('POCKET_CONSUMER_KEY'),
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
feed: {
|
||||
max: parse('SUBSCRIPTION_FEED_MAX')
|
||||
? parseInt(parse('SUBSCRIPTION_FEED_MAX'), 10)
|
||||
: 256, // default to 256
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
pg,
|
||||
client,
|
||||
|
|
@ -323,6 +337,7 @@ export function getEnv(): BackendEnv {
|
|||
azure,
|
||||
gcp,
|
||||
pocket,
|
||||
subscription,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -399,3 +399,11 @@ export const deepDelete = <T, K extends keyof T>(obj: T, keys: K[]) => {
|
|||
|
||||
return copy as Omit<T, K>
|
||||
}
|
||||
|
||||
export const isRelativeUrl = (url: string): boolean => {
|
||||
return url.startsWith('/')
|
||||
}
|
||||
|
||||
export const getAbsoluteUrl = (url: string, baseUrl: string): string => {
|
||||
return new URL(url, baseUrl).href
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,16 @@ const DOM_PURIFY_CONFIG = {
|
|||
const ARTICLE_PREFIX = 'omnivore:'
|
||||
|
||||
export const FAKE_URL_PREFIX = 'https://omnivore.app/no_url?q='
|
||||
export const RSS_PARSER_CONFIG = {
|
||||
timeout: 5000, // 5 seconds
|
||||
headers: {
|
||||
// some rss feeds require user agent
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4, text/html;q=0.2',
|
||||
},
|
||||
}
|
||||
|
||||
/** Hook that prevents DOMPurify from removing youtube iframes */
|
||||
const domPurifySanitizeHook = (
|
||||
|
|
@ -788,17 +798,23 @@ export const parseHtml = async (url: string): Promise<Feed[] | undefined> => {
|
|||
}
|
||||
}
|
||||
|
||||
export const parseFeed = async (url: string): Promise<Feed | null> => {
|
||||
export const parseFeed = async (
|
||||
url: string,
|
||||
content?: string | null
|
||||
): Promise<Feed | null> => {
|
||||
try {
|
||||
// check if url is a telegram channel
|
||||
const telegramRegex = /https:\/\/t\.me\/([a-zA-Z0-9_]+)/
|
||||
const telegramMatch = url.match(telegramRegex)
|
||||
if (telegramMatch) {
|
||||
// fetch HTML and parse feeds
|
||||
const html = await fetchHtml(url)
|
||||
if (!html) return null
|
||||
if (!content) {
|
||||
// fetch HTML and parse feeds
|
||||
content = await fetchHtml(url)
|
||||
}
|
||||
|
||||
const dom = parseHTML(html).document
|
||||
if (!content) return null
|
||||
|
||||
const dom = parseHTML(content).document
|
||||
const title = dom.querySelector('meta[property="og:title"]')
|
||||
const thumbnail = dom.querySelector('meta[property="og:image"]')
|
||||
const description = dom.querySelector('meta[property="og:description"]')
|
||||
|
|
@ -812,18 +828,12 @@ export const parseFeed = async (url: string): Promise<Feed | null> => {
|
|||
}
|
||||
}
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: 5000, // 5 seconds
|
||||
headers: {
|
||||
// some rss feeds require user agent
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4',
|
||||
},
|
||||
})
|
||||
const parser = new Parser(RSS_PARSER_CONFIG)
|
||||
|
||||
const feed = content
|
||||
? await parser.parseString(content)
|
||||
: await parser.parseURL(url)
|
||||
|
||||
const feed = await parser.parseURL(url)
|
||||
const feedUrl = feed.feedUrl || url
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1812,6 +1812,51 @@ describe('Article API', () => {
|
|||
expect(res.body.data.search.edges[1].node.id).to.eq(items[0].id)
|
||||
})
|
||||
})
|
||||
|
||||
context('when sort:score is in the query', () => {
|
||||
let items: LibraryItem[] = []
|
||||
|
||||
before(async () => {
|
||||
keyword = 'sort:score score'
|
||||
// Create some test items
|
||||
items = await createLibraryItems(
|
||||
[
|
||||
{
|
||||
user,
|
||||
title: 'score',
|
||||
slug: 'test 1',
|
||||
originalUrl: `${url}/test1`,
|
||||
},
|
||||
{
|
||||
user,
|
||||
title: 'score score',
|
||||
slug: 'test 2',
|
||||
originalUrl: `${url}/test2`,
|
||||
},
|
||||
{
|
||||
user,
|
||||
title: 'score score score',
|
||||
slug: 'test 3',
|
||||
originalUrl: `${url}/test3`,
|
||||
},
|
||||
],
|
||||
user.id
|
||||
)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await deleteLibraryItems(items, user.id)
|
||||
})
|
||||
|
||||
it('returns items in descending order of score', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.search.pageInfo.totalCount).to.eql(3)
|
||||
expect(res.body.data.search.edges[0].node.id).to.eq(items[2].id)
|
||||
expect(res.body.data.search.edges[1].node.id).to.eq(items[1].id)
|
||||
expect(res.body.data.search.edges[2].node.id).to.eq(items[0].id)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TypeaheadSearch API', () => {
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ describe('Subscriptions API', () => {
|
|||
... on SubscribeSuccess {
|
||||
subscriptions {
|
||||
id
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
... on SubscribeError {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ __ -> whitespace_character:+ {% (data) => data[0].length %}
|
|||
|
||||
whitespace_character -> [ \t\n\v\f] {% id %}
|
||||
|
||||
# Numbers
|
||||
decimal -> "-":? [0-9]:+ ("." [0-9]:+):? {%
|
||||
(data) => parseFloat(
|
||||
(data[0] || "") +
|
||||
data[1].join("") +
|
||||
(data[2] ? "."+data[2][1].join("") : "")
|
||||
)
|
||||
%}
|
||||
# # Numbers
|
||||
# decimal -> "-":? [0-9]:+ ("." [0-9]:+):? {%
|
||||
# (data) => parseFloat(
|
||||
# (data[0] || "") +
|
||||
# data[1].join("") +
|
||||
# (data[2] ? "."+data[2][1].join("") : "")
|
||||
# )
|
||||
# %}
|
||||
|
||||
# Double-quoted string
|
||||
dqstring -> "\"" dstrchar:* "\"" {% (data) => data[1].join('') %}
|
||||
|
|
@ -198,9 +198,9 @@ field ->
|
|||
| dqstring {% (data, start) => ({type: 'LiteralExpression', name: data[0], quoted: true, quotes: 'double', location: {start, end: start + data[0].length + 2}}) %}
|
||||
|
||||
expression ->
|
||||
decimal {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'LiteralExpression', quoted: false, value: Number(data.join(''))}}) %}
|
||||
| regex {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'RegexExpression', value: data.join('')}}) %}
|
||||
| range {% (data) => data[0] %}
|
||||
# decimal {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'LiteralExpression', quoted: false, value: Number(data.join(''))}}) %}
|
||||
regex {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'RegexExpression', value: data.join('')}}) %}
|
||||
# | range {% (data) => data[0] %}
|
||||
| unquoted_value {% (data, start, reject) => {
|
||||
const value = data.join('');
|
||||
|
||||
|
|
@ -236,36 +236,36 @@ expression ->
|
|||
| sqstring {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length + 2}, type: 'LiteralExpression', quoted: true, quotes: 'single', value: data.join('')}}) %}
|
||||
| dqstring {% (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length + 2}, type: 'LiteralExpression', quoted: true, quotes: 'double', value: data.join('')}}) %}
|
||||
|
||||
range ->
|
||||
range_open decimal " TO " decimal range_close {% (data, start) => {
|
||||
return {
|
||||
location: {
|
||||
start,
|
||||
},
|
||||
type: 'Tag',
|
||||
expression: {
|
||||
location: {
|
||||
start: data[0].location.start,
|
||||
end: data[4].location.start + 1,
|
||||
},
|
||||
type: 'RangeExpression',
|
||||
range: {
|
||||
min: data[1],
|
||||
minInclusive: data[0].inclusive,
|
||||
maxInclusive: data[4].inclusive,
|
||||
max: data[3],
|
||||
}
|
||||
}
|
||||
}
|
||||
} %}
|
||||
|
||||
range_open ->
|
||||
"[" {% (data, start) => ({location: {start}, inclusive: true}) %}
|
||||
| "{" {% (data, start) => ({location: {start}, inclusive: false}) %}
|
||||
|
||||
range_close ->
|
||||
"]" {% (data, start) => ({location: {start}, inclusive: true}) %}
|
||||
| "}" {% (data, start) => ({location: {start}, inclusive: false}) %}
|
||||
# range ->
|
||||
# range_open decimal " TO " decimal range_close {% (data, start) => {
|
||||
# return {
|
||||
# location: {
|
||||
# start,
|
||||
# },
|
||||
# type: 'Tag',
|
||||
# expression: {
|
||||
# location: {
|
||||
# start: data[0].location.start,
|
||||
# end: data[4].location.start + 1,
|
||||
# },
|
||||
# type: 'RangeExpression',
|
||||
# range: {
|
||||
# min: data[1],
|
||||
# minInclusive: data[0].inclusive,
|
||||
# maxInclusive: data[4].inclusive,
|
||||
# max: data[3],
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# } %}
|
||||
#
|
||||
# range_open ->
|
||||
# "[" {% (data, start) => ({location: {start}, inclusive: true}) %}
|
||||
# | "{" {% (data, start) => ({location: {start}, inclusive: false}) %}
|
||||
#
|
||||
# range_close ->
|
||||
# "]" {% (data, start) => ({location: {start}, inclusive: true}) %}
|
||||
# | "}" {% (data, start) => ({location: {start}, inclusive: false}) %}
|
||||
|
||||
comparison_operator ->
|
||||
(
|
||||
|
|
@ -292,4 +292,4 @@ regex_flags ->
|
|||
[gmiyusd]:+ {% d => d[0].join('') %}
|
||||
|
||||
unquoted_value ->
|
||||
[a-zA-Z_*?@#$\u0080-\uFFFF] [a-zA-Z\.\-_*?@#$\u0080-\uFFFF]:* {% d => d[0] + d[1].join('') %}
|
||||
[a-zA-Z_*?@#$\u0080-\uFFFF0-9] [a-zA-Z\.\-_*?@#$\u0080-\uFFFF0-9]:* {% d => d[0] + d[1].join('') %}
|
||||
|
|
|
|||
|
|
@ -42,22 +42,6 @@ const grammar: Grammar = {
|
|||
{"name": "__$ebnf$1", "symbols": ["__$ebnf$1", "whitespace_character"], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "__", "symbols": ["__$ebnf$1"], "postprocess": (data) => data[0].length},
|
||||
{"name": "whitespace_character", "symbols": [/[ \t\n\v\f]/], "postprocess": id},
|
||||
{"name": "decimal$ebnf$1", "symbols": [{"literal":"-"}], "postprocess": id},
|
||||
{"name": "decimal$ebnf$1", "symbols": [], "postprocess": () => null},
|
||||
{"name": "decimal$ebnf$2", "symbols": [/[0-9]/]},
|
||||
{"name": "decimal$ebnf$2", "symbols": ["decimal$ebnf$2", /[0-9]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "decimal$ebnf$3$subexpression$1$ebnf$1", "symbols": [/[0-9]/]},
|
||||
{"name": "decimal$ebnf$3$subexpression$1$ebnf$1", "symbols": ["decimal$ebnf$3$subexpression$1$ebnf$1", /[0-9]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "decimal$ebnf$3$subexpression$1", "symbols": [{"literal":"."}, "decimal$ebnf$3$subexpression$1$ebnf$1"]},
|
||||
{"name": "decimal$ebnf$3", "symbols": ["decimal$ebnf$3$subexpression$1"], "postprocess": id},
|
||||
{"name": "decimal$ebnf$3", "symbols": [], "postprocess": () => null},
|
||||
{"name": "decimal", "symbols": ["decimal$ebnf$1", "decimal$ebnf$2", "decimal$ebnf$3"], "postprocess":
|
||||
(data) => parseFloat(
|
||||
(data[0] || "") +
|
||||
data[1].join("") +
|
||||
(data[2] ? "."+data[2][1].join("") : "")
|
||||
)
|
||||
},
|
||||
{"name": "dqstring$ebnf$1", "symbols": []},
|
||||
{"name": "dqstring$ebnf$1", "symbols": ["dqstring$ebnf$1", "dstrchar"], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "dqstring", "symbols": [{"literal":"\""}, "dqstring$ebnf$1", {"literal":"\""}], "postprocess": (data) => data[1].join('')},
|
||||
|
|
@ -213,9 +197,7 @@ const grammar: Grammar = {
|
|||
{"name": "field", "symbols": [/[_a-zA-Z$]/, "field$ebnf$1"], "postprocess": (data, start) => ({type: 'LiteralExpression', name: data[0] + data[1].join(''), quoted: false, location: {start, end: start + (data[0] + data[1].join('')).length}})},
|
||||
{"name": "field", "symbols": ["sqstring"], "postprocess": (data, start) => ({type: 'LiteralExpression', name: data[0], quoted: true, quotes: 'single', location: {start, end: start + data[0].length + 2}})},
|
||||
{"name": "field", "symbols": ["dqstring"], "postprocess": (data, start) => ({type: 'LiteralExpression', name: data[0], quoted: true, quotes: 'double', location: {start, end: start + data[0].length + 2}})},
|
||||
{"name": "expression", "symbols": ["decimal"], "postprocess": (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'LiteralExpression', quoted: false, value: Number(data.join(''))}})},
|
||||
{"name": "expression", "symbols": ["regex"], "postprocess": (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length}, type: 'RegexExpression', value: data.join('')}})},
|
||||
{"name": "expression", "symbols": ["range"], "postprocess": (data) => data[0]},
|
||||
{"name": "expression", "symbols": ["unquoted_value"], "postprocess": (data, start, reject) => {
|
||||
const value = data.join('');
|
||||
|
||||
|
|
@ -250,32 +232,6 @@ const grammar: Grammar = {
|
|||
} },
|
||||
{"name": "expression", "symbols": ["sqstring"], "postprocess": (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length + 2}, type: 'LiteralExpression', quoted: true, quotes: 'single', value: data.join('')}})},
|
||||
{"name": "expression", "symbols": ["dqstring"], "postprocess": (data, start) => ({type: 'Tag', expression: {location: {start, end: start + data.join('').length + 2}, type: 'LiteralExpression', quoted: true, quotes: 'double', value: data.join('')}})},
|
||||
{"name": "range$string$1", "symbols": [{"literal":" "}, {"literal":"T"}, {"literal":"O"}, {"literal":" "}], "postprocess": (d) => d.join('')},
|
||||
{"name": "range", "symbols": ["range_open", "decimal", "range$string$1", "decimal", "range_close"], "postprocess": (data, start) => {
|
||||
return {
|
||||
location: {
|
||||
start,
|
||||
},
|
||||
type: 'Tag',
|
||||
expression: {
|
||||
location: {
|
||||
start: data[0].location.start,
|
||||
end: data[4].location.start + 1,
|
||||
},
|
||||
type: 'RangeExpression',
|
||||
range: {
|
||||
min: data[1],
|
||||
minInclusive: data[0].inclusive,
|
||||
maxInclusive: data[4].inclusive,
|
||||
max: data[3],
|
||||
}
|
||||
}
|
||||
}
|
||||
} },
|
||||
{"name": "range_open", "symbols": [{"literal":"["}], "postprocess": (data, start) => ({location: {start}, inclusive: true})},
|
||||
{"name": "range_open", "symbols": [{"literal":"{"}], "postprocess": (data, start) => ({location: {start}, inclusive: false})},
|
||||
{"name": "range_close", "symbols": [{"literal":"]"}], "postprocess": (data, start) => ({location: {start}, inclusive: true})},
|
||||
{"name": "range_close", "symbols": [{"literal":"}"}], "postprocess": (data, start) => ({location: {start}, inclusive: false})},
|
||||
{"name": "comparison_operator$subexpression$1", "symbols": [{"literal":":"}]},
|
||||
{"name": "comparison_operator$subexpression$1$string$1", "symbols": [{"literal":":"}, {"literal":"="}], "postprocess": (d) => d.join('')},
|
||||
{"name": "comparison_operator$subexpression$1", "symbols": ["comparison_operator$subexpression$1$string$1"]},
|
||||
|
|
@ -299,8 +255,8 @@ const grammar: Grammar = {
|
|||
{"name": "regex_flags$ebnf$1", "symbols": ["regex_flags$ebnf$1", /[gmiyusd]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "regex_flags", "symbols": ["regex_flags$ebnf$1"], "postprocess": d => d[0].join('')},
|
||||
{"name": "unquoted_value$ebnf$1", "symbols": []},
|
||||
{"name": "unquoted_value$ebnf$1", "symbols": ["unquoted_value$ebnf$1", /[a-zA-Z\.\-_*?@#$\u0080-\uFFFF]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "unquoted_value", "symbols": [/[a-zA-Z_*?@#$\u0080-\uFFFF]/, "unquoted_value$ebnf$1"], "postprocess": d => d[0] + d[1].join('')}
|
||||
{"name": "unquoted_value$ebnf$1", "symbols": ["unquoted_value$ebnf$1", /[a-zA-Z\.\-_*?@#$\u0080-\uFFFF0-9]/], "postprocess": (d) => d[0].concat([d[1]])},
|
||||
{"name": "unquoted_value", "symbols": [/[a-zA-Z_*?@#$\u0080-\uFFFF0-9]/, "unquoted_value$ebnf$1"], "postprocess": d => d[0] + d[1].join('')}
|
||||
],
|
||||
ParserStart: "main",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -93,12 +93,12 @@ test('name:/(david)|(john)/', testQuery, ['david', 'john']);
|
|||
test('name:/(David)|(John)/', testQuery, []);
|
||||
test('name:/(David)|(John)/i', testQuery, ['david', 'john']);
|
||||
|
||||
test('height:[200 TO 300]', testQuery, ['robert', 'noah']);
|
||||
test('height:[220 TO 300]', testQuery, ['robert', 'noah']);
|
||||
test('height:{220 TO 300]', testQuery, ['noah']);
|
||||
test('height:[200 TO 225]', testQuery, ['robert', 'noah']);
|
||||
test('height:[200 TO 225}', testQuery, ['robert']);
|
||||
test('height:{220 TO 225}', testQuery, []);
|
||||
test.skip('height:[200 TO 300]', testQuery, ['robert', 'noah']);
|
||||
test.skip('height:[220 TO 300]', testQuery, ['robert', 'noah']);
|
||||
test.skip('height:{220 TO 300]', testQuery, ['noah']);
|
||||
test.skip('height:[200 TO 225]', testQuery, ['robert', 'noah']);
|
||||
test.skip('height:[200 TO 225}', testQuery, ['robert']);
|
||||
test.skip('height:{220 TO 225}', testQuery, []);
|
||||
|
||||
test('NOT David', testQuery, ['john', 'mike', 'robert', 'noah', 'foo bar', 'fox']);
|
||||
test('-David', testQuery, ['john', 'mike', 'robert', 'noah', 'foo bar', 'fox']);
|
||||
|
|
@ -115,12 +115,12 @@ test('name:David OR name:John', testQuery, ['david', 'john']);
|
|||
test('name:"david" OR name:"john"', testQuery, ['david', 'john']);
|
||||
test('name:"David" OR name:"John"', testQuery, []);
|
||||
|
||||
test('height:=175', testQuery, ['john', 'mike']);
|
||||
test('height:>200', testQuery, ['robert', 'noah']);
|
||||
test('height:>220', testQuery, ['noah']);
|
||||
test('height:>=220', testQuery, ['robert', 'noah']);
|
||||
test.skip('height:=175', testQuery, ['john', 'mike']);
|
||||
test.skip('height:>200', testQuery, ['robert', 'noah']);
|
||||
test.skip('height:>220', testQuery, ['noah']);
|
||||
test.skip('height:>=220', testQuery, ['robert', 'noah']);
|
||||
|
||||
test('height:=175 AND NOT name:mike', testQuery, ['john']);
|
||||
test.skip('height:=175 AND NOT name:mike', testQuery, ['john']);
|
||||
|
||||
test('"member"', testQuery, ['robert']);
|
||||
|
||||
|
|
@ -138,9 +138,9 @@ test('subscribed:true', testQuery, ['noah']);
|
|||
test('email:/[^.:@\\s](?:[^:@\\s]*[^.:@\\s])?@[^.@\\s]+(?:\\.[^.@\\s]+)*/', testQuery, ['noah']);
|
||||
|
||||
test('phoneNumber:"404-050-2611"', testQuery, ['noah']);
|
||||
test('phoneNumber:404', testQuery, ['noah']);
|
||||
test.skip('phoneNumber:404', testQuery, ['noah']);
|
||||
|
||||
test('balance:364', testQuery, ['noah']);
|
||||
test.skip('balance:364', testQuery, ['noah']);
|
||||
|
||||
test('(David)', testQuery, ['david']);
|
||||
test('(name:david OR name:john)', testQuery, ['david', 'john']);
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ test(
|
|||
],
|
||||
);
|
||||
|
||||
test(
|
||||
test.skip(
|
||||
'matches or',
|
||||
testQuery,
|
||||
'name:foo OR name:bar OR height:=180',
|
||||
|
|
@ -181,7 +181,7 @@ test.skip(
|
|||
],
|
||||
);
|
||||
|
||||
test(
|
||||
test.skip(
|
||||
'matches number',
|
||||
testQuery,
|
||||
'height:=180',
|
||||
|
|
@ -195,7 +195,7 @@ test(
|
|||
],
|
||||
);
|
||||
|
||||
test(
|
||||
test.skip(
|
||||
'matches range',
|
||||
testQuery,
|
||||
'height:[100 TO 200]',
|
||||
|
|
|
|||
|
|
@ -529,7 +529,7 @@ test.skip('foo: bar', testQuery, {
|
|||
type: 'Tag',
|
||||
});
|
||||
|
||||
test('foo:123', testQuery, {
|
||||
test.skip('foo:123', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 7,
|
||||
|
|
@ -564,7 +564,7 @@ test('foo:123', testQuery, {
|
|||
type: 'Tag',
|
||||
});
|
||||
|
||||
test('foo:=123', testQuery, {
|
||||
test.skip('foo:=123', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 8,
|
||||
|
|
@ -636,7 +636,7 @@ test.skip('foo:= 123', testQuery, {
|
|||
type: 'Tag',
|
||||
});
|
||||
|
||||
test('foo:=-123', testQuery, {
|
||||
test.skip('foo:=-123', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 9,
|
||||
|
|
@ -671,7 +671,7 @@ test('foo:=-123', testQuery, {
|
|||
type: 'Tag',
|
||||
});
|
||||
|
||||
test('foo:=123.4', testQuery, {
|
||||
test.skip('foo:=123.4', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 10,
|
||||
|
|
@ -706,7 +706,7 @@ test('foo:=123.4', testQuery, {
|
|||
type: 'Tag',
|
||||
});
|
||||
|
||||
test('foo:>=123', testQuery, {
|
||||
test.skip('foo:>=123', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 9,
|
||||
|
|
@ -2415,7 +2415,7 @@ test('(foo:bar OR baz:qux) OR quuz:corge', testQuery, {
|
|||
type: 'LogicalExpression',
|
||||
});
|
||||
|
||||
test('[1 TO 2]', testQuery, {
|
||||
test.skip('[1 TO 2]', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 8,
|
||||
|
|
@ -2438,7 +2438,7 @@ test('[1 TO 2]', testQuery, {
|
|||
type: 'Tag',
|
||||
});
|
||||
|
||||
test('{1 TO 2]', testQuery, {
|
||||
test.skip('{1 TO 2]', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 8,
|
||||
|
|
@ -2461,7 +2461,7 @@ test('{1 TO 2]', testQuery, {
|
|||
type: 'Tag',
|
||||
});
|
||||
|
||||
test('[1 TO 2}', testQuery, {
|
||||
test.skip('[1 TO 2}', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 8,
|
||||
|
|
@ -2484,7 +2484,7 @@ test('[1 TO 2}', testQuery, {
|
|||
type: 'Tag',
|
||||
});
|
||||
|
||||
test('{1 TO 2}', testQuery, {
|
||||
test.skip('{1 TO 2}', testQuery, {
|
||||
expression: {
|
||||
location: {
|
||||
end: 8,
|
||||
|
|
|
|||
|
|
@ -52,19 +52,19 @@ test('foo:bar', testQuery);
|
|||
// https://github.com/gajus/liqe/issues/19
|
||||
test.skip('foo: bar', testQuery);
|
||||
|
||||
test('foo:123', testQuery);
|
||||
test.skip('foo:123', testQuery);
|
||||
|
||||
test('foo:=123', testQuery);
|
||||
test.skip('foo:=123', testQuery);
|
||||
|
||||
// https://github.com/gajus/liqe/issues/18
|
||||
// https://github.com/gajus/liqe/issues/19
|
||||
test.skip('foo:= 123', testQuery);
|
||||
|
||||
test('foo:=-123', testQuery);
|
||||
test.skip('foo:=-123', testQuery);
|
||||
|
||||
test('foo:=123.4', testQuery);
|
||||
test.skip('foo:=123.4', testQuery);
|
||||
|
||||
test('foo:>=123', testQuery);
|
||||
test.skip('foo:>=123', testQuery);
|
||||
|
||||
test('foo:true', testQuery);
|
||||
|
||||
|
|
@ -124,10 +124,10 @@ test('(foo:bar OR (baz:qux OR quuz:corge))', testQuery);
|
|||
|
||||
test('((foo:bar OR baz:qux) OR quuz:corge)', testQuery);
|
||||
|
||||
test('[1 TO 2]', testQuery);
|
||||
test.skip('[1 TO 2]', testQuery);
|
||||
|
||||
test('{1 TO 2]', testQuery);
|
||||
test.skip('{1 TO 2]', testQuery);
|
||||
|
||||
test('[1 TO 2}', testQuery);
|
||||
test.skip('[1 TO 2}', testQuery);
|
||||
|
||||
test('{1 TO 2}', testQuery);
|
||||
test.skip('{1 TO 2}', testQuery);
|
||||
|
|
|
|||
|
|
@ -137,8 +137,13 @@ const flairIconForLabel = (label: Label): JSX.Element | undefined => {
|
|||
|
||||
export const siteName = (
|
||||
originalArticleUrl: string,
|
||||
itemUrl: string
|
||||
itemUrl: string,
|
||||
siteName?: string
|
||||
): string => {
|
||||
if (siteName) {
|
||||
return siteName
|
||||
}
|
||||
|
||||
if (shouldHideUrl(originalArticleUrl)) {
|
||||
return ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ const GridImage = (props: GridImageProps): JSX.Element => {
|
|||
const LibraryGridCardContent = (props: LinkedItemCardProps): JSX.Element => {
|
||||
const { isChecked, setIsChecked, item } = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url)
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url, props.item.siteName)
|
||||
|
||||
const handleCheckChanged = useCallback(() => {
|
||||
const newValue = !isChecked
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ export function LibraryListCardContent(
|
|||
): JSX.Element {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const { isChecked, setIsChecked, item } = props
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url)
|
||||
const originText = siteName(props.item.originalArticleUrl, props.item.url, props.item.siteName)
|
||||
|
||||
const handleCheckChanged = useCallback(() => {
|
||||
setIsChecked(item.id, !isChecked)
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
|
|||
noteState.current.note = note
|
||||
noteState.current.isCreating = false
|
||||
setNoteText(note.annotation || '')
|
||||
} else {
|
||||
setNoteText('')
|
||||
}
|
||||
return result
|
||||
}, [articleData])
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ const FONT_FAMILIES = [
|
|||
'LXGWWenKai',
|
||||
'AtkinsonHyperlegible',
|
||||
'IBMPlexSans',
|
||||
'Fraunces',
|
||||
'Literata',
|
||||
]
|
||||
|
||||
type SettingsProps = {
|
||||
|
|
|
|||
BIN
packages/web/public/static/fonts/Fraunces/Fraunces-Italic.ttf
Normal file
BIN
packages/web/public/static/fonts/Fraunces/Fraunces-Italic.ttf
Normal file
Binary file not shown.
BIN
packages/web/public/static/fonts/Fraunces/Fraunces-Variable.ttf
Normal file
BIN
packages/web/public/static/fonts/Fraunces/Fraunces-Variable.ttf
Normal file
Binary file not shown.
BIN
packages/web/public/static/fonts/Literata/Literata-Italic.ttf
Normal file
BIN
packages/web/public/static/fonts/Literata/Literata-Italic.ttf
Normal file
Binary file not shown.
BIN
packages/web/public/static/fonts/Literata/Literata-Variable.ttf
Normal file
BIN
packages/web/public/static/fonts/Literata/Literata-Variable.ttf
Normal file
Binary file not shown.
|
|
@ -373,6 +373,52 @@ div#appleid-signin {
|
|||
src: url('/static/fonts/Lexend/Lexend-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
src: url('/static/fonts/Fraunces/Fraunces-Variable.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 700;
|
||||
font-style: bold;
|
||||
src: url('/static/fonts/Fraunces/Fraunces-Variable.ttf');
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
src: url('/static/fonts/Fraunces/Fraunces-Italic.ttf');
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: 'Literata';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
src: url('/static/fonts/Literata/Literata-Variable.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Literata';
|
||||
font-weight: 700;
|
||||
font-style: bold;
|
||||
src: url('/static/fonts/Literata/Literata-Variable.ttf');
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: 'Literata';
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
src: url('/static/fonts/Literata/Literata-Italic.ttf');
|
||||
}
|
||||
|
||||
|
||||
.dropdown-arrow {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue