mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #2912 from omnivore-app/main
Web production deployment
This commit is contained in:
commit
baacbf6e62
26 changed files with 400 additions and 96 deletions
|
|
@ -1400,7 +1400,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.33.0;
|
||||
MARKETING_VERSION = 1.34.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
|
|
@ -1435,7 +1435,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.33.0;
|
||||
MARKETING_VERSION = 1.34.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1490,7 +1490,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.33.0;
|
||||
MARKETING_VERSION = 1.34.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1831,7 +1831,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.33.0;
|
||||
MARKETING_VERSION = 1.34.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -116,8 +116,16 @@ struct CreateLabelView: View {
|
|||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
TextField("Label Name", text: $newLabelName)
|
||||
TextField(LocalText.labelNamePlaceholder, text: $newLabelName)
|
||||
.textFieldStyle(StandardTextFieldStyle())
|
||||
.onChange(of: newLabelName) { inputLabelName in
|
||||
newLabelName = String(inputLabelName.prefix(viewModel.labelNameMaxLength))
|
||||
}
|
||||
|
||||
Text("\(newLabelName.count)/\(viewModel.labelNameMaxLength)")
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.foregroundColor(newLabelName.count < viewModel.labelNameMaxLength ? .gray : .red)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHGrid(rows: rows, alignment: .top, spacing: 20) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import SwiftUI
|
|||
import Views
|
||||
|
||||
@MainActor final class LabelsViewModel: ObservableObject {
|
||||
let labelNameMaxLength = 64
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var selectedLabels = Set<LinkedItemLabel>()
|
||||
@Published var unselectedLabels = Set<LinkedItemLabel>()
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public enum LocalText {
|
|||
public static let labelsViewAssignNameColor = localText(key: "labelsViewAssignNameColor")
|
||||
public static let createLabelMessage = localText(key: "createLabelMessage")
|
||||
public static let labelsPurposeDescription = localText(key: "labelsPurposeDescription")
|
||||
public static let labelNamePlaceholder = localText(key: "labelNamePlaceholder")
|
||||
|
||||
// Manage Account View
|
||||
public static let manageAccountDelete = localText(key: "manageAccountDelete")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"labelsViewAssignNameColor" = "Assign a name and color.";
|
||||
"createLabelMessage" = "Create a new Label";
|
||||
"labelsPurposeDescription" = "Use labels to create curated collections of links.";
|
||||
"labelNamePlaceholder" = "Label Name";
|
||||
|
||||
// Manage Account View
|
||||
"manageAccountDelete" = "Delete Account";
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"labelsViewAssignNameColor" = "Asigna un nombre y un color.";
|
||||
"createLabelMessage" = "Crea una nueva Etiqueta";
|
||||
"labelsPurposeDescription" = "Usa Etiquetas para coleccione de enlaces organizadas.";
|
||||
"labelNamePlaceholder" = "Nombre de la etiqueta";
|
||||
|
||||
// Manage Account View
|
||||
"manageAccountDelete" = "Borrar Cuenta";
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"labelsViewAssignNameColor" = "指定名称和颜色。";
|
||||
"createLabelMessage" = "创建新标签";
|
||||
"labelsPurposeDescription" = "使用标签创建精选的链接集合。";
|
||||
"labelNamePlaceholder" = "标签名称";
|
||||
|
||||
// Manage Account View
|
||||
"manageAccountDelete" = "删除帐户";
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import {
|
||||
EntitySubscriberInterface,
|
||||
EventSubscriber,
|
||||
InsertEvent,
|
||||
} from 'typeorm'
|
||||
import { Profile } from '../../entity/profile'
|
||||
import { createDefaultFiltersForUser } from '../../services/create_user'
|
||||
import { addPopularReadsForNewUser } from '../../services/popular_reads'
|
||||
|
||||
@EventSubscriber()
|
||||
export class AddPopularReadsToNewUser
|
||||
implements EntitySubscriberInterface<Profile>
|
||||
{
|
||||
listenTo() {
|
||||
return Profile
|
||||
}
|
||||
|
||||
async afterInsert(event: InsertEvent<Profile>): Promise<void> {
|
||||
await addPopularReadsForNewUser(event.entity.user.id, event.manager)
|
||||
}
|
||||
}
|
||||
|
||||
@EventSubscriber()
|
||||
export class AddDefaultFiltersToNewUser
|
||||
implements EntitySubscriberInterface<Profile>
|
||||
{
|
||||
listenTo() {
|
||||
return Profile
|
||||
}
|
||||
|
||||
async afterInsert(event: InsertEvent<Profile>): Promise<void> {
|
||||
await createDefaultFiltersForUser(event.manager)(event.entity.user.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -649,6 +649,7 @@ export const searchResolver = authorized<
|
|||
size: first + 1, // fetch one more item to get next cursor
|
||||
sort: searchQuery.sort,
|
||||
includePending: true,
|
||||
includeContent: params.includeContent || false,
|
||||
...searchQuery,
|
||||
},
|
||||
uid
|
||||
|
|
@ -706,9 +707,9 @@ export const typeaheadSearchResolver = authorized<
|
|||
TypeaheadSearchSuccess,
|
||||
TypeaheadSearchError,
|
||||
QueryTypeaheadSearchArgs
|
||||
>(async (_obj, { query, first }, { log }) => {
|
||||
>(async (_obj, { query, first }, { log, uid }) => {
|
||||
try {
|
||||
const items = await findLibraryItemsByPrefix(query, first || undefined)
|
||||
const items = await findLibraryItemsByPrefix(query, uid, first || undefined)
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@ export function contentServiceRouter() {
|
|||
.withRepository(libraryItemRepository)
|
||||
.createQueryBuilder('item')
|
||||
.innerJoinAndSelect('item.uploadFile', 'file')
|
||||
.where('file.id = :fileId', { fileId })
|
||||
.where('item.user = :userId', { userId: uploadFile.user.id })
|
||||
.andWhere('file.id = :fileId', { fileId })
|
||||
.getOne(),
|
||||
undefined,
|
||||
uploadFile.user.id
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { analytics } from '../utils/analytics'
|
|||
import { IntercomClient } from '../utils/intercom'
|
||||
import { logger } from '../utils/logger'
|
||||
import { validateUsername } from '../utils/usernamePolicy'
|
||||
import { addPopularReadsForNewUser } from './popular_reads'
|
||||
import { sendConfirmationEmail } from './send_emails'
|
||||
|
||||
export const MAX_RECORDS_LIMIT = 1000
|
||||
|
|
@ -103,6 +104,9 @@ export const createUser = async (input: {
|
|||
})
|
||||
}
|
||||
|
||||
await addPopularReadsForNewUser(user.id, t)
|
||||
await createDefaultFiltersForUser(t)(user.id)
|
||||
|
||||
return [user, profile]
|
||||
}
|
||||
)
|
||||
|
|
@ -146,12 +150,15 @@ export const createUser = async (input: {
|
|||
return [user, profile]
|
||||
}
|
||||
|
||||
export const createDefaultFiltersForUser =
|
||||
const createDefaultFiltersForUser =
|
||||
(t: EntityManager) =>
|
||||
async (userId: string): Promise<Filter[]> => {
|
||||
const defaultFilters = [
|
||||
{ name: 'Inbox', filter: 'in:inbox' },
|
||||
{ name: 'Continue Reading', filter: 'in:inbox sort:read-desc is:unread' },
|
||||
{
|
||||
name: 'Continue Reading',
|
||||
filter: 'in:inbox sort:read-desc is:reading',
|
||||
},
|
||||
{ name: 'Non-Feed Items', filter: 'in:library' },
|
||||
{ name: 'Highlights', filter: 'has:highlights mode:highlights' },
|
||||
{ name: 'Unlabeled', filter: 'no:label' },
|
||||
|
|
|
|||
|
|
@ -136,8 +136,11 @@ export const updateHighlight = async (
|
|||
export const deleteHighlightById = async (highlightId: string) => {
|
||||
return authTrx(async (tx) => {
|
||||
const highlightRepo = tx.withRepository(highlightRepository)
|
||||
const highlight = await highlightRepo.findOneByOrFail({
|
||||
id: highlightId,
|
||||
const highlight = await highlightRepo.findOneOrFail({
|
||||
where: { id: highlightId },
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
})
|
||||
|
||||
await highlightRepo.delete(highlightId)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { LibraryItem } from '../../entity/library_item'
|
|||
import { env } from '../../env'
|
||||
import { wait } from '../../utils/helpers'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { getHighlightUrl } from '../highlights'
|
||||
import { findHighlightsByLibraryItemId, getHighlightUrl } from '../highlights'
|
||||
import { IntegrationService } from './integration'
|
||||
|
||||
interface ReadwiseHighlight {
|
||||
|
|
@ -64,10 +64,14 @@ export class ReadwiseIntegration extends IntegrationService {
|
|||
): Promise<boolean> => {
|
||||
let result = true
|
||||
|
||||
const highlights = items.flatMap(this.libraryItemToReadwiseHighlight)
|
||||
const highlights = await Promise.all(
|
||||
items.map((item) =>
|
||||
this.libraryItemToReadwiseHighlight(item, integration.user.id)
|
||||
)
|
||||
)
|
||||
// If there are no highlights, we will skip the sync
|
||||
if (highlights.length > 0) {
|
||||
result = await this.syncWithReadwise(integration.token, highlights)
|
||||
result = await this.syncWithReadwise(integration.token, highlights.flat())
|
||||
}
|
||||
|
||||
// update integration syncedAt if successful
|
||||
|
|
@ -84,10 +88,17 @@ export class ReadwiseIntegration extends IntegrationService {
|
|||
return result
|
||||
}
|
||||
|
||||
libraryItemToReadwiseHighlight = (item: LibraryItem): ReadwiseHighlight[] => {
|
||||
if (!item.highlights) return []
|
||||
libraryItemToReadwiseHighlight = async (
|
||||
item: LibraryItem,
|
||||
userId: string
|
||||
): Promise<ReadwiseHighlight[]> => {
|
||||
let highlights = item.highlights
|
||||
if (!highlights) {
|
||||
highlights = await findHighlightsByLibraryItemId(item.id, userId)
|
||||
}
|
||||
|
||||
const category = item.siteName === 'Twitter' ? 'tweets' : 'articles'
|
||||
return item.highlights
|
||||
return highlights
|
||||
.map((highlight) => {
|
||||
// filter out highlights that are not of type highlight or have no quote
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import { DeepPartial, SelectQueryBuilder } from 'typeorm'
|
||||
import {
|
||||
Between,
|
||||
DeepPartial,
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
MoreThan,
|
||||
Not,
|
||||
SelectQueryBuilder,
|
||||
} from 'typeorm'
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
|
||||
import { EntityLabel } from '../entity/entity_label'
|
||||
import { Highlight } from '../entity/highlight'
|
||||
|
|
@ -6,7 +15,7 @@ import { Label } from '../entity/label'
|
|||
import { LibraryItem, LibraryItemState } from '../entity/library_item'
|
||||
import { BulkActionType } from '../generated/graphql'
|
||||
import { createPubSubClient, EntityType } from '../pubsub'
|
||||
import { authTrx } from '../repository'
|
||||
import { authTrx, getColumns } from '../repository'
|
||||
import { libraryItemRepository } from '../repository/library_item'
|
||||
import { wordsCount } from '../utils/helpers'
|
||||
import {
|
||||
|
|
@ -17,6 +26,7 @@ import {
|
|||
LabelFilter,
|
||||
LabelFilterType,
|
||||
NoFilter,
|
||||
RangeFilter,
|
||||
ReadFilter,
|
||||
Sort,
|
||||
SortBy,
|
||||
|
|
@ -42,6 +52,7 @@ export interface SearchArgs {
|
|||
recommendedBy?: string
|
||||
includeContent?: boolean
|
||||
noFilters?: NoFilter[]
|
||||
rangeFilters?: RangeFilter[]
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
|
|
@ -104,10 +115,14 @@ const buildWhereClause = (
|
|||
if (args.inFilter !== InFilter.ALL) {
|
||||
switch (args.inFilter) {
|
||||
case InFilter.INBOX:
|
||||
queryBuilder.andWhere('library_item.archived_at IS NULL')
|
||||
queryBuilder.andWhere({
|
||||
archivedAt: IsNull(),
|
||||
})
|
||||
break
|
||||
case InFilter.ARCHIVE:
|
||||
queryBuilder.andWhere('library_item.archived_at IS NOT NULL')
|
||||
queryBuilder.andWhere({
|
||||
archivedAt: Not(IsNull()),
|
||||
})
|
||||
break
|
||||
case InFilter.TRASH:
|
||||
// return only deleted pages within 14 days
|
||||
|
|
@ -117,16 +132,20 @@ const buildWhereClause = (
|
|||
break
|
||||
case InFilter.SUBSCRIPTION:
|
||||
queryBuilder
|
||||
.andWhere('library_item.subscription IS NOT NULL')
|
||||
.andWhere("NOT ('library' ILIKE ANY (library_item.label_names))")
|
||||
.andWhere('library_item.archived_at IS NULL')
|
||||
.andWhere({
|
||||
subscription: Not(IsNull()),
|
||||
archivedAt: IsNull(),
|
||||
})
|
||||
break
|
||||
case InFilter.LIBRARY:
|
||||
queryBuilder
|
||||
.andWhere(
|
||||
"(library_item.subscription IS NULL OR 'library' ILIKE ANY (library_item.label_names))"
|
||||
)
|
||||
.andWhere('library_item.archived_at IS NULL')
|
||||
.andWhere({
|
||||
archivedAt: IsNull(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -134,10 +153,17 @@ const buildWhereClause = (
|
|||
if (args.readFilter !== ReadFilter.ALL) {
|
||||
switch (args.readFilter) {
|
||||
case ReadFilter.READ:
|
||||
queryBuilder.andWhere('library_item.reading_progress_top_percent >= 98')
|
||||
queryBuilder.andWhere({
|
||||
readingProgressBottomPercent: MoreThan(98),
|
||||
})
|
||||
break
|
||||
case ReadFilter.READING:
|
||||
queryBuilder.andWhere({ readingProgressBottomPercent: Between(2, 98) })
|
||||
break
|
||||
case ReadFilter.UNREAD:
|
||||
queryBuilder.andWhere('library_item.reading_progress_top_percent < 98')
|
||||
queryBuilder.andWhere({
|
||||
readingProgressBottomPercent: LessThan(2),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -146,12 +172,10 @@ const buildWhereClause = (
|
|||
args.hasFilters.forEach((filter) => {
|
||||
switch (filter) {
|
||||
case HasFilter.HIGHLIGHTS:
|
||||
queryBuilder.andWhere(
|
||||
'array_length(library_item.highlight_annotations, 1) > 0'
|
||||
)
|
||||
queryBuilder.andWhere("library_item.highlight_annotations <> '{}'")
|
||||
break
|
||||
case HasFilter.LABELS:
|
||||
queryBuilder.andWhere('array_length(library_item.label_names, 1) > 0')
|
||||
queryBuilder.andWhere("library_item.label_names <> '{}'")
|
||||
break
|
||||
}
|
||||
})
|
||||
|
|
@ -223,18 +247,20 @@ const buildWhereClause = (
|
|||
}
|
||||
|
||||
if (args.ids && args.ids.length > 0) {
|
||||
queryBuilder.andWhere('library_item.id IN (:...ids)', { ids: args.ids })
|
||||
queryBuilder.andWhere({
|
||||
id: In(args.ids),
|
||||
})
|
||||
}
|
||||
|
||||
if (!args.includePending) {
|
||||
queryBuilder.andWhere('library_item.state != :state', {
|
||||
state: LibraryItemState.Processing,
|
||||
queryBuilder.andWhere({
|
||||
state: Not(LibraryItemState.Processing),
|
||||
})
|
||||
}
|
||||
|
||||
if (!args.includeDeleted && args.inFilter !== InFilter.TRASH) {
|
||||
queryBuilder.andWhere('library_item.state != :state', {
|
||||
state: LibraryItemState.Deleted,
|
||||
queryBuilder.andWhere({
|
||||
state: Not(LibraryItemState.Deleted),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +284,22 @@ const buildWhereClause = (
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (args.includeContent) {
|
||||
queryBuilder.addSelect('library_item.readableContent')
|
||||
}
|
||||
|
||||
if (args.rangeFilters && args.rangeFilters.length > 0) {
|
||||
args.rangeFilters.forEach((filter, i) => {
|
||||
const param = `range_${filter.field}_${i}`
|
||||
queryBuilder.andWhere(
|
||||
`library_item.${filter.field} ${filter.operator} :${param}`,
|
||||
{
|
||||
[param]: filter.value,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const searchLibraryItems = async (
|
||||
|
|
@ -271,12 +313,21 @@ export const searchLibraryItems = async (
|
|||
// default sort by saved_at
|
||||
const sortField = sort?.by || SortBy.SAVED
|
||||
|
||||
const selectColumns = getColumns(libraryItemRepository)
|
||||
.map((column) => `library_item.${column}`)
|
||||
.filter(
|
||||
(column) =>
|
||||
column !== 'library_item.readableContent' &&
|
||||
column !== 'library_item.originalContent'
|
||||
)
|
||||
|
||||
// add pagination and sorting
|
||||
return authTrx(
|
||||
async (tx) => {
|
||||
const queryBuilder = tx
|
||||
.createQueryBuilder(LibraryItem, 'library_item')
|
||||
.where('library_item.user_id = :userId', { userId })
|
||||
.select(selectColumns)
|
||||
.where({ user: { id: userId } })
|
||||
|
||||
// build the where clause
|
||||
buildWhereClause(queryBuilder, args)
|
||||
|
|
@ -328,7 +379,8 @@ export const findLibraryItemByUrl = async (
|
|||
.leftJoinAndSelect('recommendations.recommender', 'recommender')
|
||||
.leftJoinAndSelect('recommender.profile', 'profile')
|
||||
.leftJoinAndSelect('recommendations.group', 'group')
|
||||
.where('library_item.original_url = :url', { url })
|
||||
.where('library_item.user_id = :userId', { userId })
|
||||
.andWhere('library_item.original_url = :url', { url })
|
||||
.getOne(),
|
||||
undefined,
|
||||
userId
|
||||
|
|
@ -434,6 +486,7 @@ export const createLibraryItem = async (
|
|||
|
||||
export const findLibraryItemsByPrefix = async (
|
||||
prefix: string,
|
||||
userId: string,
|
||||
limit = 5
|
||||
): Promise<LibraryItem[]> => {
|
||||
const prefixWildcard = `${prefix}%`
|
||||
|
|
@ -441,10 +494,11 @@ export const findLibraryItemsByPrefix = async (
|
|||
return authTrx(async (tx) =>
|
||||
tx
|
||||
.createQueryBuilder(LibraryItem, 'library_item')
|
||||
.where('library_item.title ILIKE :prefix', { prefix: prefixWildcard })
|
||||
.orWhere('library_item.site_name ILIKE :prefix', {
|
||||
prefix: prefixWildcard,
|
||||
})
|
||||
.where('library_item.user_id = :userId', { userId })
|
||||
.andWhere(
|
||||
'(library_item.title ILIKE :prefix OR library_item.site_name ILIKE :prefix)',
|
||||
{ prefix: prefixWildcard }
|
||||
)
|
||||
.orderBy('library_item.savedAt', 'DESC')
|
||||
.limit(limit)
|
||||
.getMany()
|
||||
|
|
@ -460,7 +514,8 @@ export const countByCreatedAt = async (
|
|||
async (tx) =>
|
||||
tx
|
||||
.createQueryBuilder(LibraryItem, 'library_item')
|
||||
.where('library_item.created_at between :startDate and :endDate', {
|
||||
.where('library_item.user_id = :userId', { userId })
|
||||
.andWhere('library_item.created_at between :startDate and :endDate', {
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
|
|
@ -557,10 +612,12 @@ export const deleteLibraryItems = async (
|
|||
)
|
||||
}
|
||||
|
||||
export const deleteLibraryItemByUrl = async (url: string, userId?: string) => {
|
||||
export const deleteLibraryItemByUrl = async (url: string, userId: string) => {
|
||||
return authTrx(
|
||||
async (tx) =>
|
||||
tx.withRepository(libraryItemRepository).delete({ originalUrl: url }),
|
||||
tx
|
||||
.withRepository(libraryItemRepository)
|
||||
.delete({ originalUrl: url, user: { id: userId } }),
|
||||
undefined,
|
||||
userId
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { InputMaybe, PageType, SortParams } from '../generated/graphql'
|
|||
export enum ReadFilter {
|
||||
ALL,
|
||||
READ,
|
||||
READING,
|
||||
UNREAD,
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ export interface SearchFilter {
|
|||
ids: string[]
|
||||
recommendedBy?: string
|
||||
noFilters: NoFilter[]
|
||||
rangeFilters: RangeFilter[]
|
||||
}
|
||||
|
||||
export enum LabelFilterType {
|
||||
|
|
@ -63,6 +65,12 @@ export interface DateFilter {
|
|||
endDate?: Date
|
||||
}
|
||||
|
||||
export interface RangeFilter {
|
||||
field: string
|
||||
operator: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export enum SortBy {
|
||||
SAVED = 'savedAt',
|
||||
UPDATED = 'updatedAt',
|
||||
|
|
@ -103,6 +111,8 @@ const parseIsFilter = (str: string | undefined): ReadFilter => {
|
|||
switch (str?.toUpperCase()) {
|
||||
case 'READ':
|
||||
return ReadFilter.READ
|
||||
case 'READING':
|
||||
return ReadFilter.READING
|
||||
case 'UNREAD':
|
||||
return ReadFilter.UNREAD
|
||||
}
|
||||
|
|
@ -255,6 +265,43 @@ const parseDateFilter = (
|
|||
}
|
||||
}
|
||||
|
||||
const parseRangeFilter = (
|
||||
field: string,
|
||||
str?: string
|
||||
): RangeFilter | undefined => {
|
||||
if (str === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (field.toUpperCase()) {
|
||||
case 'WORDSCOUNT':
|
||||
field = 'word_count'
|
||||
break
|
||||
case 'READPOSITION':
|
||||
field = 'reading_progress_bottom_percent'
|
||||
break
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
const operatorRegex = /([<>]=?)/
|
||||
const operator = str.match(operatorRegex)?.[0]
|
||||
if (!operator) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const value = str.replace(operatorRegex, '')
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
field,
|
||||
operator,
|
||||
value: Number(value),
|
||||
}
|
||||
}
|
||||
|
||||
const parseFieldFilter = (
|
||||
field: string,
|
||||
str?: string
|
||||
|
|
@ -323,6 +370,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
|
|||
matchFilters: [],
|
||||
ids: [],
|
||||
noFilters: [],
|
||||
rangeFilters: [],
|
||||
}
|
||||
|
||||
if (!searchQuery) {
|
||||
|
|
@ -337,6 +385,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
|
|||
matchFilters: [],
|
||||
ids: [],
|
||||
noFilters: [],
|
||||
rangeFilters: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -364,6 +413,8 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
|
|||
'site',
|
||||
'note',
|
||||
'rss',
|
||||
'wordsCount',
|
||||
'readPosition',
|
||||
],
|
||||
tokenize: true,
|
||||
})
|
||||
|
|
@ -460,6 +511,12 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
|
|||
case 'mode':
|
||||
// mode is ignored and used only by the frontend
|
||||
break
|
||||
case 'readPosition':
|
||||
case 'wordsCount': {
|
||||
const rangeFilter = parseRangeFilter(keyword.keyword, keyword.value)
|
||||
rangeFilter && result.rangeFilters.push(rangeFilter)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -818,6 +818,7 @@ describe('Article API', () => {
|
|||
let keyword = ''
|
||||
|
||||
before(async () => {
|
||||
const readingProgressArray = [0, 2, 97, 98, 100]
|
||||
// Create some test items
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const itemToSave: DeepPartial<LibraryItem> = {
|
||||
|
|
@ -827,6 +828,7 @@ describe('Article API', () => {
|
|||
slug: 'test slug',
|
||||
originalUrl: `${url}/${i}`,
|
||||
siteName: 'Example',
|
||||
readingProgressBottomPercent: readingProgressArray[i],
|
||||
}
|
||||
const item = await createLibraryItem(itemToSave, user.id)
|
||||
items.push(item)
|
||||
|
|
@ -887,15 +889,39 @@ describe('Article API', () => {
|
|||
keyword = `'${searchedKeyword}' is:unread`
|
||||
})
|
||||
|
||||
it('should return unread articles in descending order', async () => {
|
||||
it('returns unread articles in descending order', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.search.edges.length).to.eq(5)
|
||||
expect(res.body.data.search.edges.length).to.eq(1)
|
||||
expect(res.body.data.search.edges[0].node.id).to.eq(items[0].id)
|
||||
})
|
||||
})
|
||||
|
||||
context('when is:reading is in the query', () => {
|
||||
before(() => {
|
||||
keyword = `'${searchedKeyword}' is:reading`
|
||||
})
|
||||
|
||||
it('returns reading articles in descending order', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.search.edges.length).to.eq(3)
|
||||
expect(res.body.data.search.edges[0].node.id).to.eq(items[3].id)
|
||||
expect(res.body.data.search.edges[1].node.id).to.eq(items[2].id)
|
||||
expect(res.body.data.search.edges[2].node.id).to.eq(items[1].id)
|
||||
})
|
||||
})
|
||||
|
||||
context('when is:read is in the query', () => {
|
||||
before(() => {
|
||||
keyword = `'${searchedKeyword}' is:read`
|
||||
})
|
||||
|
||||
it('returns fully read articles in descending order', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.search.edges.length).to.eq(1)
|
||||
expect(res.body.data.search.edges[0].node.id).to.eq(items[4].id)
|
||||
expect(res.body.data.search.edges[1].node.id).to.eq(items[3].id)
|
||||
expect(res.body.data.search.edges[2].node.id).to.eq(items[2].id)
|
||||
expect(res.body.data.search.edges[3].node.id).to.eq(items[1].id)
|
||||
expect(res.body.data.search.edges[4].node.id).to.eq(items[0].id)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1122,7 +1148,7 @@ describe('Article API', () => {
|
|||
slug: 'test slug 2',
|
||||
originalUrl: `${url}/test2`,
|
||||
archivedAt: new Date(),
|
||||
readingProgressTopPercent: 100,
|
||||
readingProgressBottomPercent: 100,
|
||||
},
|
||||
{
|
||||
user,
|
||||
|
|
@ -1140,7 +1166,7 @@ describe('Article API', () => {
|
|||
await deleteLibraryItems(items, user.id)
|
||||
})
|
||||
|
||||
it('returns unfinished archived items', async () => {
|
||||
it('returns unread archived items', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.search.pageInfo.totalCount).to.eq(1)
|
||||
|
|
@ -1221,7 +1247,7 @@ describe('Article API', () => {
|
|||
slug: 'test slug 2',
|
||||
originalUrl: `${url}/test2`,
|
||||
deletedAt: new Date(),
|
||||
readingProgressTopPercent: 100,
|
||||
readingProgressBottomPercent: 100,
|
||||
},
|
||||
{
|
||||
user,
|
||||
|
|
@ -1246,6 +1272,104 @@ describe('Article API', () => {
|
|||
expect(res.body.data.search.edges[0].node.id).to.eq(items[0].id)
|
||||
})
|
||||
})
|
||||
|
||||
context('when readPosition:>20 readPosition:<50 is in the query', () => {
|
||||
let items: LibraryItem[] = []
|
||||
|
||||
before(async () => {
|
||||
keyword = 'readPosition:>20 readPosition:<50'
|
||||
// Create some test items
|
||||
items = await createLibraryItems(
|
||||
[
|
||||
{
|
||||
user,
|
||||
title: 'test title 1',
|
||||
readableContent: '<p>test 1</p>',
|
||||
slug: 'test slug 1',
|
||||
originalUrl: `${url}/test1`,
|
||||
readingProgressBottomPercent: 40,
|
||||
},
|
||||
{
|
||||
user,
|
||||
title: 'test title 2',
|
||||
readableContent: '<p>test 2</p>',
|
||||
slug: 'test slug 2',
|
||||
originalUrl: `${url}/test2`,
|
||||
readingProgressBottomPercent: 10,
|
||||
},
|
||||
{
|
||||
user,
|
||||
title: 'test title 3',
|
||||
readableContent: '<p>test 3</p>',
|
||||
slug: 'test slug 3',
|
||||
originalUrl: `${url}/test3`,
|
||||
readingProgressBottomPercent: 100,
|
||||
},
|
||||
],
|
||||
user.id
|
||||
)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await deleteLibraryItems(items, user.id)
|
||||
})
|
||||
|
||||
it('returns items with reading progress between 20% and 50% exclusively', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.search.pageInfo.totalCount).to.eq(1)
|
||||
expect(res.body.data.search.edges[0].node.id).to.eq(items[0].id)
|
||||
})
|
||||
})
|
||||
|
||||
context('when wordsCount:>=10000 wordsCount:<=20000 is in the query', () => {
|
||||
let items: LibraryItem[] = []
|
||||
|
||||
before(async () => {
|
||||
keyword = 'wordsCount:>=10000 wordsCount:<=20000'
|
||||
// Create some test items
|
||||
items = await createLibraryItems(
|
||||
[
|
||||
{
|
||||
user,
|
||||
title: 'test title 1',
|
||||
readableContent: '<p>test 1</p>',
|
||||
slug: 'test slug 1',
|
||||
originalUrl: `${url}/test1`,
|
||||
wordCount: 10000,
|
||||
},
|
||||
{
|
||||
user,
|
||||
title: 'test title 2',
|
||||
readableContent: '<p>test 2</p>',
|
||||
slug: 'test slug 2',
|
||||
originalUrl: `${url}/test2`,
|
||||
wordCount: 8000,
|
||||
},
|
||||
{
|
||||
user,
|
||||
title: 'test title 3',
|
||||
readableContent: '<p>test 3</p>',
|
||||
slug: 'test slug 3',
|
||||
originalUrl: `${url}/test3`,
|
||||
wordCount: 100000,
|
||||
},
|
||||
],
|
||||
user.id
|
||||
)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await deleteLibraryItems(items, user.id)
|
||||
})
|
||||
|
||||
it('returns items with words count between 10000 and 20000 inclusively', async () => {
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
|
||||
expect(res.body.data.search.pageInfo.totalCount).to.eq(1)
|
||||
expect(res.body.data.search.edges[0].node.id).to.eq(items[0].id)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TypeaheadSearch API', () => {
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ async def main():
|
|||
ES_USERNAME, ES_PASSWORD), retry_on_timeout=True)
|
||||
|
||||
try:
|
||||
updated_user_ids = []
|
||||
# updated_user_ids = []
|
||||
|
||||
print(await es_client.info())
|
||||
|
||||
|
|
@ -454,8 +454,8 @@ async def main():
|
|||
library_items.append(library_item)
|
||||
library_items_original_ids.append(doc_id)
|
||||
|
||||
if user_id not in updated_user_ids:
|
||||
updated_user_ids.append(user_id)
|
||||
# if user_id not in updated_user_ids:
|
||||
# updated_user_ids.append(user_id)
|
||||
|
||||
# convert labels to postgres format
|
||||
if 'labels' in source:
|
||||
|
|
@ -551,7 +551,7 @@ async def main():
|
|||
|
||||
print('Migration complete', END_TIME)
|
||||
|
||||
await assert_data(db_conn, es_client, updated_user_ids, uploaded_files)
|
||||
# await assert_data(db_conn, es_client, updated_user_ids, uploaded_files)
|
||||
except Exception as err:
|
||||
print('Migration error', err)
|
||||
finally:
|
||||
|
|
|
|||
11
packages/db/migrations/0130.do.update_continue_reading_filter.sql
Executable file
11
packages/db/migrations/0130.do.update_continue_reading_filter.sql
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
-- Type: DO
|
||||
-- Name: update_continue_reading_filter
|
||||
-- Description: Update the filter value in the Continue Reading filter
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE omnivore.filters
|
||||
SET filter = 'in:inbox sort:read-desc is:reading'
|
||||
WHERE name = 'Continue Reading' AND default_filter = true;
|
||||
|
||||
COMMIT;
|
||||
11
packages/db/migrations/0130.undo.update_continue_reading_filter.sql
Executable file
11
packages/db/migrations/0130.undo.update_continue_reading_filter.sql
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
-- Type: UNDO
|
||||
-- Name: update_continue_reading_filter
|
||||
-- Description: Update the filter value in the Continue Reading filter
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE omnivore.filters
|
||||
SET filter = 'in:inbox sort:read-desc is:unread'
|
||||
WHERE name = 'Continue Reading' AND default_filter = true;
|
||||
|
||||
COMMIT;
|
||||
11
packages/db/migrations/0131.do.add_default_value_to_updated_at.sql
Executable file
11
packages/db/migrations/0131.do.add_default_value_to_updated_at.sql
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
-- Type: DO
|
||||
-- Name: add_default_value_to_updated_at
|
||||
-- Description: Add default = now() to updated_at field in profile, labels and highlight table
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE omnivore.highlight SET updated_at = created_at WHERE updated_at IS NULL;
|
||||
ALTER TABLE omnivore.highlight
|
||||
ALTER COLUMN updated_at SET DEFAULT current_timestamp;
|
||||
|
||||
COMMIT;
|
||||
9
packages/db/migrations/0131.undo.add_default_value_to_updated_at.sql
Executable file
9
packages/db/migrations/0131.undo.add_default_value_to_updated_at.sql
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
-- Type: UNDO
|
||||
-- Name: add_default_value_to_updated_at
|
||||
-- Description: Add default = now() to updated_at field in profile, labels and highlight table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE omnivore.highlight ALTER COLUMN updated_at DROP NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
11
packages/db/migrations/0132.do.add_highlight_not_null.sql
Executable file
11
packages/db/migrations/0132.do.add_highlight_not_null.sql
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
-- Type: DO
|
||||
-- Name: add_highlight_not_null
|
||||
-- Description: Add a not null clause to the highlight updated_at column
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE omnivore.highlight
|
||||
ALTER COLUMN updated_at SET NOT NULL ;
|
||||
|
||||
|
||||
COMMIT;
|
||||
10
packages/db/migrations/0132.undo.add_highlight_not_null.sql
Executable file
10
packages/db/migrations/0132.undo.add_highlight_not_null.sql
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
-- Type: UNDO
|
||||
-- Name: add_highlight_not_null
|
||||
-- Description: Add a not null clause to the highlight updated_at column
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE omnivore.highlight
|
||||
ALTER COLUMN updated_at DROP NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -70,7 +70,7 @@ const sendUpdateSubscriptionMutation = async (
|
|||
)
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
return !!response.data.data.savePage
|
||||
return !!response.data.data.updateSubscription.subscription
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error('update subscription mutation error', error.message)
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export const EmptyLibrary = (props: EmptyLibraryProps) => {
|
|||
switch (props.searchTerm) {
|
||||
case 'in:inbox':
|
||||
return 'inbox'
|
||||
case 'in:inbox sort:read-desc is:unread':
|
||||
case 'in:inbox sort:read-desc is:reading':
|
||||
return 'continue'
|
||||
case 'in:library':
|
||||
return 'non-feed'
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ function Subscriptions(
|
|||
},
|
||||
}
|
||||
}),
|
||||
[props.subscriptions]
|
||||
[sortedSubscriptions]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -241,7 +241,7 @@ function Subscriptions(
|
|||
text="Newsletters"
|
||||
{...props}
|
||||
/>
|
||||
{(props.subscriptions ?? []).map((item) => {
|
||||
{(sortedSubscriptions ?? []).map((item) => {
|
||||
switch (item.type) {
|
||||
case SubscriptionType.NEWSLETTER:
|
||||
return (
|
||||
|
|
|
|||
Loading…
Reference in a new issue