diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index a61592edc..32ee2a7a5 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -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 = ""; diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift index 7473d8b25..96c859699 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsView.swift @@ -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) { diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index 10525fa35..50b11ca6a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -5,6 +5,8 @@ import SwiftUI import Views @MainActor final class LabelsViewModel: ObservableObject { + let labelNameMaxLength = 64 + @Published var isLoading = false @Published var selectedLabels = Set() @Published var unselectedLabels = Set() diff --git a/apple/OmnivoreKit/Sources/Views/LocalText.swift b/apple/OmnivoreKit/Sources/Views/LocalText.swift index 13a5ba82c..2fc984a17 100644 --- a/apple/OmnivoreKit/Sources/Views/LocalText.swift +++ b/apple/OmnivoreKit/Sources/Views/LocalText.swift @@ -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") diff --git a/apple/OmnivoreKit/Sources/Views/Resources/en.lproj/Localizable.strings b/apple/OmnivoreKit/Sources/Views/Resources/en.lproj/Localizable.strings index 127dd0811..d69e034ce 100644 --- a/apple/OmnivoreKit/Sources/Views/Resources/en.lproj/Localizable.strings +++ b/apple/OmnivoreKit/Sources/Views/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/apple/OmnivoreKit/Sources/Views/Resources/es.lproj/Localizable.strings b/apple/OmnivoreKit/Sources/Views/Resources/es.lproj/Localizable.strings index 848d343f5..dff64a42a 100644 --- a/apple/OmnivoreKit/Sources/Views/Resources/es.lproj/Localizable.strings +++ b/apple/OmnivoreKit/Sources/Views/Resources/es.lproj/Localizable.strings @@ -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"; diff --git a/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings b/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings index f8a5ca95c..7cee43c8d 100644 --- a/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings +++ b/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings @@ -20,6 +20,7 @@ "labelsViewAssignNameColor" = "指定名称和颜色。"; "createLabelMessage" = "创建新标签"; "labelsPurposeDescription" = "使用标签创建精选的链接集合。"; +"labelNamePlaceholder" = "标签名称"; // Manage Account View "manageAccountDelete" = "删除帐户"; diff --git a/packages/api/src/events/user/profile_created.ts b/packages/api/src/events/user/profile_created.ts deleted file mode 100644 index 0af9326e0..000000000 --- a/packages/api/src/events/user/profile_created.ts +++ /dev/null @@ -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 -{ - listenTo() { - return Profile - } - - async afterInsert(event: InsertEvent): Promise { - await addPopularReadsForNewUser(event.entity.user.id, event.manager) - } -} - -@EventSubscriber() -export class AddDefaultFiltersToNewUser - implements EntitySubscriberInterface -{ - listenTo() { - return Profile - } - - async afterInsert(event: InsertEvent): Promise { - await createDefaultFiltersForUser(event.manager)(event.entity.user.id) - } -} diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index f2ae8f800..1c191d0ec 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -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) => ({ diff --git a/packages/api/src/routers/svc/content.ts b/packages/api/src/routers/svc/content.ts index aed246711..ce184ea5b 100644 --- a/packages/api/src/routers/svc/content.ts +++ b/packages/api/src/routers/svc/content.ts @@ -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 diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index 4648658c8..6fd8e2033 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -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 => { 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' }, diff --git a/packages/api/src/services/highlights.ts b/packages/api/src/services/highlights.ts index 07cf73eba..4f281d3df 100644 --- a/packages/api/src/services/highlights.ts +++ b/packages/api/src/services/highlights.ts @@ -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) diff --git a/packages/api/src/services/integrations/readwise.ts b/packages/api/src/services/integrations/readwise.ts index 28c444cfc..0c6676ebd 100644 --- a/packages/api/src/services/integrations/readwise.ts +++ b/packages/api/src/services/integrations/readwise.ts @@ -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 => { 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 => { + 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 ( diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 1dcdb146d..49a363acf 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -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 => { 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 ) diff --git a/packages/api/src/utils/search.ts b/packages/api/src/utils/search.ts index 5bbe09cde..357875449 100644 --- a/packages/api/src/utils/search.ts +++ b/packages/api/src/utils/search.ts @@ -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 + } } } } diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index 2959a2072..d8c2545d9 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -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 = { @@ -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: '

test 1

', + slug: 'test slug 1', + originalUrl: `${url}/test1`, + readingProgressBottomPercent: 40, + }, + { + user, + title: 'test title 2', + readableContent: '

test 2

', + slug: 'test slug 2', + originalUrl: `${url}/test2`, + readingProgressBottomPercent: 10, + }, + { + user, + title: 'test title 3', + readableContent: '

test 3

', + 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: '

test 1

', + slug: 'test slug 1', + originalUrl: `${url}/test1`, + wordCount: 10000, + }, + { + user, + title: 'test title 2', + readableContent: '

test 2

', + slug: 'test slug 2', + originalUrl: `${url}/test2`, + wordCount: 8000, + }, + { + user, + title: 'test title 3', + readableContent: '

test 3

', + 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', () => { diff --git a/packages/db/elastic_migrations/migrate_from_elastic.py b/packages/db/elastic_migrations/migrate_from_elastic.py index 98bc1a6aa..a27179ddb 100755 --- a/packages/db/elastic_migrations/migrate_from_elastic.py +++ b/packages/db/elastic_migrations/migrate_from_elastic.py @@ -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: diff --git a/packages/db/migrations/0130.do.update_continue_reading_filter.sql b/packages/db/migrations/0130.do.update_continue_reading_filter.sql new file mode 100755 index 000000000..c3e4b21cd --- /dev/null +++ b/packages/db/migrations/0130.do.update_continue_reading_filter.sql @@ -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; diff --git a/packages/db/migrations/0130.undo.update_continue_reading_filter.sql b/packages/db/migrations/0130.undo.update_continue_reading_filter.sql new file mode 100755 index 000000000..288485f0e --- /dev/null +++ b/packages/db/migrations/0130.undo.update_continue_reading_filter.sql @@ -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; diff --git a/packages/db/migrations/0131.do.add_default_value_to_updated_at.sql b/packages/db/migrations/0131.do.add_default_value_to_updated_at.sql new file mode 100755 index 000000000..32fdd4a13 --- /dev/null +++ b/packages/db/migrations/0131.do.add_default_value_to_updated_at.sql @@ -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; diff --git a/packages/db/migrations/0131.undo.add_default_value_to_updated_at.sql b/packages/db/migrations/0131.undo.add_default_value_to_updated_at.sql new file mode 100755 index 000000000..96cf9e21f --- /dev/null +++ b/packages/db/migrations/0131.undo.add_default_value_to_updated_at.sql @@ -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; diff --git a/packages/db/migrations/0132.do.add_highlight_not_null.sql b/packages/db/migrations/0132.do.add_highlight_not_null.sql new file mode 100755 index 000000000..47ef70a80 --- /dev/null +++ b/packages/db/migrations/0132.do.add_highlight_not_null.sql @@ -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; diff --git a/packages/db/migrations/0132.undo.add_highlight_not_null.sql b/packages/db/migrations/0132.undo.add_highlight_not_null.sql new file mode 100755 index 000000000..72c6447f0 --- /dev/null +++ b/packages/db/migrations/0132.undo.add_highlight_not_null.sql @@ -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; diff --git a/packages/rss-handler/src/index.ts b/packages/rss-handler/src/index.ts index 2440ef520..9c37e22f9 100644 --- a/packages/rss-handler/src/index.ts +++ b/packages/rss-handler/src/index.ts @@ -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) diff --git a/packages/web/components/templates/homeFeed/EmptyLibrary.tsx b/packages/web/components/templates/homeFeed/EmptyLibrary.tsx index 04b6df2e0..72da7891b 100644 --- a/packages/web/components/templates/homeFeed/EmptyLibrary.tsx +++ b/packages/web/components/templates/homeFeed/EmptyLibrary.tsx @@ -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' diff --git a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx index ff113ea3d..11adec862 100644 --- a/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx +++ b/packages/web/components/templates/homeFeed/LibraryFilterMenu.tsx @@ -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 (