Merge pull request #3054 from omnivore-app/feature/social-feeds

feature: following
This commit is contained in:
Hongbo Wu 2023-11-15 11:19:14 +08:00 committed by GitHub
commit c59b4ca97e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 931 additions and 349 deletions

View file

@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
@Entity()
export class Feed {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('text')
title!: string
@Column('text')
url!: string
@Column('text')
author?: string
@Column('text')
description?: string
@Column('text')
image?: string
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
@Column('timestamptz')
publishedAt?: Date | null
}

View file

@ -196,4 +196,16 @@ export class LibraryItem {
@Column('text', { nullable: true })
recommenderNames?: string[] | null
@Column('jsonb')
links?: any | null
@Column('text')
previewContent?: string | null
@Column('text')
previewContentType?: string | null
@Column('text')
folder!: string
}

View file

@ -70,4 +70,10 @@ export class Subscription {
@Column('timestamp', { nullable: true })
scheduledAt?: Date | null
@Column('boolean')
isPrivate?: boolean | null
@Column('boolean')
autoAddToLibrary?: boolean | null
}

View file

@ -725,6 +725,19 @@ export type Feature = {
updatedAt?: Maybe<Scalars['Date']>;
};
export type Feed = {
__typename?: 'Feed';
author?: Maybe<Scalars['String']>;
createdAt: Scalars['Date'];
description?: Maybe<Scalars['String']>;
id: Scalars['ID'];
image?: Maybe<Scalars['String']>;
publishedAt?: Maybe<Scalars['Date']>;
title: Scalars['String'];
updatedAt: Scalars['Date'];
url: Scalars['String'];
};
export type FeedArticle = {
__typename?: 'FeedArticle';
annotationsCount?: Maybe<Scalars['Int']>;
@ -762,6 +775,37 @@ export type FeedArticlesSuccess = {
pageInfo: PageInfo;
};
export type FeedEdge = {
__typename?: 'FeedEdge';
cursor: Scalars['String'];
node: Feed;
};
export type FeedsError = {
__typename?: 'FeedsError';
errorCodes: Array<FeedsErrorCode>;
};
export enum FeedsErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type FeedsInput = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
query?: InputMaybe<Scalars['String']>;
sort?: InputMaybe<SortParams>;
};
export type FeedsResult = FeedsError | FeedsSuccess;
export type FeedsSuccess = {
__typename?: 'FeedsSuccess';
edges: Array<FeedEdge>;
pageInfo: PageInfo;
};
export type Filter = {
__typename?: 'Filter';
category: Scalars['String'];
@ -1239,6 +1283,24 @@ export type MoveLabelSuccess = {
label: Label;
};
export type MoveToFolderError = {
__typename?: 'MoveToFolderError';
errorCodes: Array<MoveToFolderErrorCode>;
};
export enum MoveToFolderErrorCode {
AlreadyExists = 'ALREADY_EXISTS',
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type MoveToFolderResult = MoveToFolderError | MoveToFolderSuccess;
export type MoveToFolderSuccess = {
__typename?: 'MoveToFolderSuccess';
articleSavingRequest: ArticleSavingRequest;
};
export type Mutation = {
__typename?: 'Mutation';
addPopularRead: AddPopularReadResult;
@ -1268,6 +1330,7 @@ export type Mutation = {
mergeHighlight: MergeHighlightResult;
moveFilter: MoveFilterResult;
moveLabel: MoveLabelResult;
moveToFolder: MoveToFolderResult;
optInFeature: OptInFeatureResult;
recommend: RecommendResult;
recommendHighlights: RecommendHighlightsResult;
@ -1432,6 +1495,12 @@ export type MutationMoveLabelArgs = {
};
export type MutationMoveToFolderArgs = {
folder: Scalars['String'];
id: Scalars['ID'];
};
export type MutationOptInFeatureArgs = {
input: OptInFeatureInput;
};
@ -1725,6 +1794,7 @@ export type Query = {
article: ArticleResult;
articleSavingRequest: ArticleSavingRequestResult;
deviceTokens: DeviceTokensResult;
feeds: FeedsResult;
filters: FiltersResult;
getUserPersonalization: GetUserPersonalizationResult;
groups: GroupsResult;
@ -1762,6 +1832,11 @@ export type QueryArticleSavingRequestArgs = {
};
export type QueryFeedsArgs = {
input: FeedsInput;
};
export type QueryRulesArgs = {
enabled?: InputMaybe<Scalars['Boolean']>;
};
@ -2628,6 +2703,8 @@ export enum SubscribeErrorCode {
}
export type SubscribeInput = {
autoAddToLibrary?: InputMaybe<Scalars['Boolean']>;
isPrivate?: InputMaybe<Scalars['Boolean']>;
subscriptionType?: InputMaybe<SubscriptionType>;
url: Scalars['String'];
};
@ -2641,11 +2718,13 @@ export type SubscribeSuccess = {
export type Subscription = {
__typename?: 'Subscription';
autoAddToLibrary?: Maybe<Scalars['Boolean']>;
count: Scalars['Int'];
createdAt: Scalars['Date'];
description?: Maybe<Scalars['String']>;
icon?: Maybe<Scalars['String']>;
id: Scalars['ID'];
isPrivate?: Maybe<Scalars['Boolean']>;
lastFetchedAt?: Maybe<Scalars['Date']>;
name: Scalars['String'];
newsletterEmail?: Maybe<Scalars['String']>;
@ -2986,8 +3065,10 @@ export enum UpdateSubscriptionErrorCode {
}
export type UpdateSubscriptionInput = {
autoAddToLibrary?: InputMaybe<Scalars['Boolean']>;
description?: InputMaybe<Scalars['String']>;
id: Scalars['ID'];
isPrivate?: InputMaybe<Scalars['Boolean']>;
lastFetchedAt?: InputMaybe<Scalars['Date']>;
lastFetchedChecksum?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
@ -3459,12 +3540,19 @@ export type ResolversTypes = {
DeviceTokensResult: ResolversTypes['DeviceTokensError'] | ResolversTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: ResolverTypeWrapper<DeviceTokensSuccess>;
Feature: ResolverTypeWrapper<Feature>;
Feed: ResolverTypeWrapper<Feed>;
FeedArticle: ResolverTypeWrapper<FeedArticle>;
FeedArticleEdge: ResolverTypeWrapper<FeedArticleEdge>;
FeedArticlesError: ResolverTypeWrapper<FeedArticlesError>;
FeedArticlesErrorCode: FeedArticlesErrorCode;
FeedArticlesResult: ResolversTypes['FeedArticlesError'] | ResolversTypes['FeedArticlesSuccess'];
FeedArticlesSuccess: ResolverTypeWrapper<FeedArticlesSuccess>;
FeedEdge: ResolverTypeWrapper<FeedEdge>;
FeedsError: ResolverTypeWrapper<FeedsError>;
FeedsErrorCode: FeedsErrorCode;
FeedsInput: FeedsInput;
FeedsResult: ResolversTypes['FeedsError'] | ResolversTypes['FeedsSuccess'];
FeedsSuccess: ResolverTypeWrapper<FeedsSuccess>;
Filter: ResolverTypeWrapper<Filter>;
FiltersError: ResolverTypeWrapper<FiltersError>;
FiltersErrorCode: FiltersErrorCode;
@ -3557,6 +3645,10 @@ export type ResolversTypes = {
MoveLabelInput: MoveLabelInput;
MoveLabelResult: ResolversTypes['MoveLabelError'] | ResolversTypes['MoveLabelSuccess'];
MoveLabelSuccess: ResolverTypeWrapper<MoveLabelSuccess>;
MoveToFolderError: ResolverTypeWrapper<MoveToFolderError>;
MoveToFolderErrorCode: MoveToFolderErrorCode;
MoveToFolderResult: ResolversTypes['MoveToFolderError'] | ResolversTypes['MoveToFolderSuccess'];
MoveToFolderSuccess: ResolverTypeWrapper<MoveToFolderSuccess>;
Mutation: ResolverTypeWrapper<{}>;
NewsletterEmail: ResolverTypeWrapper<NewsletterEmail>;
NewsletterEmailsError: ResolverTypeWrapper<NewsletterEmailsError>;
@ -3939,11 +4031,17 @@ export type ResolversParentTypes = {
DeviceTokensResult: ResolversParentTypes['DeviceTokensError'] | ResolversParentTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: DeviceTokensSuccess;
Feature: Feature;
Feed: Feed;
FeedArticle: FeedArticle;
FeedArticleEdge: FeedArticleEdge;
FeedArticlesError: FeedArticlesError;
FeedArticlesResult: ResolversParentTypes['FeedArticlesError'] | ResolversParentTypes['FeedArticlesSuccess'];
FeedArticlesSuccess: FeedArticlesSuccess;
FeedEdge: FeedEdge;
FeedsError: FeedsError;
FeedsInput: FeedsInput;
FeedsResult: ResolversParentTypes['FeedsError'] | ResolversParentTypes['FeedsSuccess'];
FeedsSuccess: FeedsSuccess;
Filter: Filter;
FiltersError: FiltersError;
FiltersResult: ResolversParentTypes['FiltersError'] | ResolversParentTypes['FiltersSuccess'];
@ -4016,6 +4114,9 @@ export type ResolversParentTypes = {
MoveLabelInput: MoveLabelInput;
MoveLabelResult: ResolversParentTypes['MoveLabelError'] | ResolversParentTypes['MoveLabelSuccess'];
MoveLabelSuccess: MoveLabelSuccess;
MoveToFolderError: MoveToFolderError;
MoveToFolderResult: ResolversParentTypes['MoveToFolderError'] | ResolversParentTypes['MoveToFolderSuccess'];
MoveToFolderSuccess: MoveToFolderSuccess;
Mutation: {};
NewsletterEmail: NewsletterEmail;
NewsletterEmailsError: NewsletterEmailsError;
@ -4734,6 +4835,19 @@ export type FeatureResolvers<ContextType = ResolverContext, ParentType extends R
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeedResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Feed'] = ResolversParentTypes['Feed']> = {
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
image?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
publishedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeedArticleResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FeedArticle'] = ResolversParentTypes['FeedArticle']> = {
annotationsCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
article?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
@ -4769,6 +4883,27 @@ export type FeedArticlesSuccessResolvers<ContextType = ResolverContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeedEdgeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FeedEdge'] = ResolversParentTypes['FeedEdge']> = {
cursor?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
node?: Resolver<ResolversTypes['Feed'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeedsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FeedsError'] = ResolversParentTypes['FeedsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['FeedsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeedsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FeedsResult'] = ResolversParentTypes['FeedsResult']> = {
__resolveType: TypeResolveFn<'FeedsError' | 'FeedsSuccess', ParentType, ContextType>;
};
export type FeedsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FeedsSuccess'] = ResolversParentTypes['FeedsSuccess']> = {
edges?: Resolver<Array<ResolversTypes['FeedEdge']>, ParentType, ContextType>;
pageInfo?: Resolver<ResolversTypes['PageInfo'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FilterResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Filter'] = ResolversParentTypes['Filter']> = {
category?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
@ -5126,6 +5261,20 @@ export type MoveLabelSuccessResolvers<ContextType = ResolverContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type MoveToFolderErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['MoveToFolderError'] = ResolversParentTypes['MoveToFolderError']> = {
errorCodes?: Resolver<Array<ResolversTypes['MoveToFolderErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type MoveToFolderResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['MoveToFolderResult'] = ResolversParentTypes['MoveToFolderResult']> = {
__resolveType: TypeResolveFn<'MoveToFolderError' | 'MoveToFolderSuccess', ParentType, ContextType>;
};
export type MoveToFolderSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['MoveToFolderSuccess'] = ResolversParentTypes['MoveToFolderSuccess']> = {
articleSavingRequest?: Resolver<ResolversTypes['ArticleSavingRequest'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type MutationResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = {
addPopularRead?: Resolver<ResolversTypes['AddPopularReadResult'], ParentType, ContextType, RequireFields<MutationAddPopularReadArgs, 'name'>>;
bulkAction?: Resolver<ResolversTypes['BulkActionResult'], ParentType, ContextType, RequireFields<MutationBulkActionArgs, 'action' | 'query'>>;
@ -5154,6 +5303,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
mergeHighlight?: Resolver<ResolversTypes['MergeHighlightResult'], ParentType, ContextType, RequireFields<MutationMergeHighlightArgs, 'input'>>;
moveFilter?: Resolver<ResolversTypes['MoveFilterResult'], ParentType, ContextType, RequireFields<MutationMoveFilterArgs, 'input'>>;
moveLabel?: Resolver<ResolversTypes['MoveLabelResult'], ParentType, ContextType, RequireFields<MutationMoveLabelArgs, 'input'>>;
moveToFolder?: Resolver<ResolversTypes['MoveToFolderResult'], ParentType, ContextType, RequireFields<MutationMoveToFolderArgs, 'folder' | 'id'>>;
optInFeature?: Resolver<ResolversTypes['OptInFeatureResult'], ParentType, ContextType, RequireFields<MutationOptInFeatureArgs, 'input'>>;
recommend?: Resolver<ResolversTypes['RecommendResult'], ParentType, ContextType, RequireFields<MutationRecommendArgs, 'input'>>;
recommendHighlights?: Resolver<ResolversTypes['RecommendHighlightsResult'], ParentType, ContextType, RequireFields<MutationRecommendHighlightsArgs, 'input'>>;
@ -5266,6 +5416,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
article?: Resolver<ResolversTypes['ArticleResult'], ParentType, ContextType, RequireFields<QueryArticleArgs, 'slug' | 'username'>>;
articleSavingRequest?: Resolver<ResolversTypes['ArticleSavingRequestResult'], ParentType, ContextType, Partial<QueryArticleSavingRequestArgs>>;
deviceTokens?: Resolver<ResolversTypes['DeviceTokensResult'], ParentType, ContextType>;
feeds?: Resolver<ResolversTypes['FeedsResult'], ParentType, ContextType, RequireFields<QueryFeedsArgs, 'input'>>;
filters?: Resolver<ResolversTypes['FiltersResult'], ParentType, ContextType>;
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
@ -5800,11 +5951,13 @@ export type SubscribeSuccessResolvers<ContextType = ResolverContext, ParentType
};
export type SubscriptionResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Subscription'] = ResolversParentTypes['Subscription']> = {
autoAddToLibrary?: SubscriptionResolver<Maybe<ResolversTypes['Boolean']>, "autoAddToLibrary", ParentType, ContextType>;
count?: SubscriptionResolver<ResolversTypes['Int'], "count", ParentType, ContextType>;
createdAt?: SubscriptionResolver<ResolversTypes['Date'], "createdAt", ParentType, ContextType>;
description?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "description", ParentType, ContextType>;
icon?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "icon", ParentType, ContextType>;
id?: SubscriptionResolver<ResolversTypes['ID'], "id", ParentType, ContextType>;
isPrivate?: SubscriptionResolver<Maybe<ResolversTypes['Boolean']>, "isPrivate", ParentType, ContextType>;
lastFetchedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "lastFetchedAt", ParentType, ContextType>;
name?: SubscriptionResolver<ResolversTypes['String'], "name", ParentType, ContextType>;
newsletterEmail?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "newsletterEmail", ParentType, ContextType>;
@ -6287,11 +6440,16 @@ export type Resolvers<ContextType = ResolverContext> = {
DeviceTokensResult?: DeviceTokensResultResolvers<ContextType>;
DeviceTokensSuccess?: DeviceTokensSuccessResolvers<ContextType>;
Feature?: FeatureResolvers<ContextType>;
Feed?: FeedResolvers<ContextType>;
FeedArticle?: FeedArticleResolvers<ContextType>;
FeedArticleEdge?: FeedArticleEdgeResolvers<ContextType>;
FeedArticlesError?: FeedArticlesErrorResolvers<ContextType>;
FeedArticlesResult?: FeedArticlesResultResolvers<ContextType>;
FeedArticlesSuccess?: FeedArticlesSuccessResolvers<ContextType>;
FeedEdge?: FeedEdgeResolvers<ContextType>;
FeedsError?: FeedsErrorResolvers<ContextType>;
FeedsResult?: FeedsResultResolvers<ContextType>;
FeedsSuccess?: FeedsSuccessResolvers<ContextType>;
Filter?: FilterResolvers<ContextType>;
FiltersError?: FiltersErrorResolvers<ContextType>;
FiltersResult?: FiltersResultResolvers<ContextType>;
@ -6355,6 +6513,9 @@ export type Resolvers<ContextType = ResolverContext> = {
MoveLabelError?: MoveLabelErrorResolvers<ContextType>;
MoveLabelResult?: MoveLabelResultResolvers<ContextType>;
MoveLabelSuccess?: MoveLabelSuccessResolvers<ContextType>;
MoveToFolderError?: MoveToFolderErrorResolvers<ContextType>;
MoveToFolderResult?: MoveToFolderResultResolvers<ContextType>;
MoveToFolderSuccess?: MoveToFolderSuccessResolvers<ContextType>;
Mutation?: MutationResolvers<ContextType>;
NewsletterEmail?: NewsletterEmailResolvers<ContextType>;
NewsletterEmailsError?: NewsletterEmailsErrorResolvers<ContextType>;

View file

@ -641,6 +641,18 @@ type Feature {
updatedAt: Date
}
type Feed {
author: String
createdAt: Date!
description: String
id: ID!
image: String
publishedAt: Date
title: String!
updatedAt: Date!
url: String!
}
type FeedArticle {
annotationsCount: Int
article: Article!
@ -674,6 +686,34 @@ type FeedArticlesSuccess {
pageInfo: PageInfo!
}
type FeedEdge {
cursor: String!
node: Feed!
}
type FeedsError {
errorCodes: [FeedsErrorCode!]!
}
enum FeedsErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
input FeedsInput {
after: String
first: Int
query: String
sort: SortParams
}
union FeedsResult = FeedsError | FeedsSuccess
type FeedsSuccess {
edges: [FeedEdge!]!
pageInfo: PageInfo!
}
type Filter {
category: String!
createdAt: Date!
@ -1109,6 +1149,22 @@ type MoveLabelSuccess {
label: Label!
}
type MoveToFolderError {
errorCodes: [MoveToFolderErrorCode!]!
}
enum MoveToFolderErrorCode {
ALREADY_EXISTS
BAD_REQUEST
UNAUTHORIZED
}
union MoveToFolderResult = MoveToFolderError | MoveToFolderSuccess
type MoveToFolderSuccess {
articleSavingRequest: ArticleSavingRequest!
}
type Mutation {
addPopularRead(name: String!): AddPopularReadResult!
bulkAction(action: BulkActionType!, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult!
@ -1137,6 +1193,7 @@ type Mutation {
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!
moveFilter(input: MoveFilterInput!): MoveFilterResult!
moveLabel(input: MoveLabelInput!): MoveLabelResult!
moveToFolder(folder: String!, id: ID!): MoveToFolderResult!
optInFeature(input: OptInFeatureInput!): OptInFeatureResult!
recommend(input: RecommendInput!): RecommendResult!
recommendHighlights(input: RecommendHighlightsInput!): RecommendHighlightsResult!
@ -1294,6 +1351,7 @@ type Query {
article(format: String, slug: String!, username: String!): ArticleResult!
articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult!
deviceTokens: DeviceTokensResult!
feeds(input: FeedsInput!): FeedsResult!
filters: FiltersResult!
getUserPersonalization: GetUserPersonalizationResult!
groups: GroupsResult!
@ -2071,6 +2129,8 @@ enum SubscribeErrorCode {
}
input SubscribeInput {
autoAddToLibrary: Boolean
isPrivate: Boolean
subscriptionType: SubscriptionType
url: String!
}
@ -2082,11 +2142,13 @@ type SubscribeSuccess {
}
type Subscription {
autoAddToLibrary: Boolean
count: Int!
createdAt: Date!
description: String
icon: String
id: ID!
isPrivate: Boolean
lastFetchedAt: Date
name: String!
newsletterEmail: String
@ -2400,8 +2462,10 @@ enum UpdateSubscriptionErrorCode {
}
input UpdateSubscriptionInput {
autoAddToLibrary: Boolean
description: String
id: ID!
isPrivate: Boolean
lastFetchedAt: Date
lastFetchedChecksum: String
name: String

View file

@ -0,0 +1,33 @@
import { ILike } from 'typeorm'
import { appDataSource } from '../data_source'
import { Feed } from '../entity/feed'
export const feedRepository = appDataSource.getRepository(Feed).extend({
async searchFeeds(
query = '',
take = 10,
skip = 0,
orderBy = 'title',
order = 'ASC'
) {
const where = []
if (query !== '') {
query = `%${query}%`
where.push({ title: ILike(query) }, { url: ILike(query) })
}
const feeds = await this.find({
where,
order: { [orderBy]: order },
take,
skip,
})
const count = await this.countBy(where)
return {
feeds,
count,
}
},
})

View file

@ -6,7 +6,7 @@
import { Readability } from '@omnivore/readability'
import graphqlFields from 'graphql-fields'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import { LibraryItem } from '../../entity/library_item'
import { env } from '../../env'
import {
ArticleError,
@ -95,7 +95,11 @@ import {
ParsedContentPuppeteer,
parsePreparedContent,
} from '../../utils/parser'
import { parseSearchQuery, sortParamsToSort } from '../../utils/search'
import {
InFilter,
parseSearchQuery,
sortParamsToSort,
} from '../../utils/search'
import { getStorageFileDetails } from '../../utils/uploads'
import { itemTypeForContentType } from '../upload_files'
@ -401,7 +405,7 @@ export const getArticleResolver = authorized<
})
)
if (!libraryItem || libraryItem.state === LibraryItemState.Deleted) {
if (!libraryItem || libraryItem.folder === InFilter.TRASH) {
return { errorCodes: [ArticleErrorCode.NotFound] }
}
@ -523,8 +527,8 @@ export const setBookmarkArticleResolver = authorized<
const deletedLibraryItem = await updateLibraryItem(
articleID,
{
state: LibraryItemState.Deleted,
deletedAt: new Date(),
folder: InFilter.TRASH,
savedAt: new Date(),
},
uid,
pubsub
@ -736,7 +740,7 @@ export const updatesSinceResolver = authorized<
const sort = sortParamsToSort(sortParams)
const startCursor = after || ''
const size = first || 10
const size = Math.min(first || 10, 100) // limit to 100 items
let startDate = new Date(since)
if (isNaN(startDate.getTime())) {
// for android app compatibility
@ -750,6 +754,7 @@ export const updatesSinceResolver = authorized<
includeDeleted: true,
dateFilters: [{ field: 'updatedAt', startDate }],
sort,
inFilter: InFilter.ALL,
},
uid
)
@ -861,7 +866,7 @@ export const setFavoriteArticleResolver = authorized<
})
const getUpdateReason = (libraryItem: LibraryItem, since: Date) => {
if (libraryItem.state === LibraryItemState.Deleted) {
if (libraryItem.folder === InFilter.TRASH) {
return UpdateReason.Deleted
}
if (libraryItem.createdAt >= since) {

View file

@ -0,0 +1,146 @@
import { LibraryItem } from '../../entity/library_item'
import {
FeedEdge,
FeedsError,
FeedsErrorCode,
FeedsSuccess,
MoveToFolderError,
MoveToFolderErrorCode,
MoveToFolderSuccess,
MutationMoveToFolderArgs,
QueryFeedsArgs,
} from '../../generated/graphql'
import { feedRepository } from '../../repository/feed'
import { createPageSaveRequest } from '../../services/create_page_save_request'
import { updateLibraryItem } from '../../services/library_item'
import { analytics } from '../../utils/analytics'
import {
authorized,
libraryItemToArticleSavingRequest,
} from '../../utils/helpers'
export const feedsResolver = authorized<
FeedsSuccess,
FeedsError,
QueryFeedsArgs
>(async (_, { input }, { log }) => {
try {
const startCursor = input.after || ''
const start =
startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0
const first = Math.min(input.first || 10, 100) // cap at 100
const { feeds, count } = await feedRepository.searchFeeds(
input.query || '',
first + 1, // fetch one extra to check if there is a next page
start,
input.sort?.by,
input.sort?.order || undefined
)
const hasNextPage = feeds.length > first
const endCursor = String(start + feeds.length - (hasNextPage ? 1 : 0))
if (hasNextPage) {
// remove an extra if exists
feeds.pop()
}
const edges: FeedEdge[] = feeds.map((feed) => ({
node: feed,
cursor: endCursor,
}))
return {
__typename: 'FeedsSuccess',
edges,
pageInfo: {
hasPreviousPage: start > 0,
hasNextPage,
startCursor,
endCursor,
totalCount: count,
},
}
} catch (error) {
log.error('Error fetching feeds', error)
return {
errorCodes: [FeedsErrorCode.BadRequest],
}
}
})
export const moveToFolderResolver = authorized<
MoveToFolderSuccess,
MoveToFolderError,
MutationMoveToFolderArgs
>(async (_, { id, folder }, { authTrx, pubsub, uid }) => {
analytics.track({
userId: uid,
event: 'move_to_folder',
properties: {
id,
folder,
},
})
const item = await authTrx((tx) =>
tx.getRepository(LibraryItem).findOne({
where: {
id,
},
relations: ['user'],
})
)
if (!item) {
return {
errorCodes: [MoveToFolderErrorCode.Unauthorized],
}
}
if (item.folder === folder) {
return {
errorCodes: [MoveToFolderErrorCode.AlreadyExists],
}
}
const savedAt = new Date()
// if the content is not fetched yet, create a page save request
if (!item.readableContent) {
const articleSavingRequest = await createPageSaveRequest({
userId: uid,
url: item.originalUrl,
articleSavingRequestId: id,
priority: 'high',
publishedAt: item.publishedAt || undefined,
savedAt,
pubsub,
})
return {
__typename: 'MoveToFolderSuccess',
articleSavingRequest,
}
}
const updatedItem = await updateLibraryItem(
item.id,
{
folder,
savedAt,
},
uid,
pubsub
)
return {
__typename: 'MoveToFolderSuccess',
articleSavingRequest: libraryItemToArticleSavingRequest(
updatedItem.user,
updatedItem
),
}
})

View file

@ -29,6 +29,7 @@ import {
generateUploadFilePathName,
} from '../utils/uploads'
import { optInFeatureResolver } from './features'
import { feedsResolver, moveToFolderResolver } from './following'
import { uploadImportFileResolver } from './importers/uploadImportFileResolver'
import {
addPopularReadResolver,
@ -215,6 +216,7 @@ export const functionResolvers = {
updateSubscription: updateSubscriptionResolver,
updateFilter: updateFilterResolver,
updateEmail: updateEmailResolver,
moveToFolder: moveToFolderResolver,
},
Query: {
me: getMeUserResolver,
@ -246,6 +248,7 @@ export const functionResolvers = {
filters: filtersResolver,
groups: groupsResolver,
recentEmails: recentEmailsResolver,
feeds: feedsResolver,
},
User: {
async intercomHash(

View file

@ -1,4 +1,3 @@
import { LibraryItemState } from '../../entity/library_item'
import { env } from '../../env'
import {
ArchiveLinkError,
@ -9,6 +8,7 @@ import {
import { updateLibraryItem } from '../../services/library_item'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
import { InFilter } from '../../utils/search'
// export const updateLinkShareInfoResolver = authorized<
// UpdateLinkShareInfoSuccess,
@ -66,10 +66,8 @@ export const setLinkArchivedResolver = authorized<
await updateLibraryItem(
args.input.linkId,
{
archivedAt: args.input.archived ? new Date() : null,
state: args.input.archived
? LibraryItemState.Archived
: LibraryItemState.Succeeded,
savedAt: new Date(),
folder: args.input.archived ? InFilter.ARCHIVE : InFilter.INBOX,
},
uid
)

View file

@ -175,7 +175,7 @@ export const subscribeResolver = authorized<
SubscribeSuccessPartial,
SubscribeError,
MutationSubscribeArgs
>(async (_, { input }, { uid, log }) => {
>(async (_, { input }, { authTrx, uid, log }) => {
try {
analytics.track({
userId: uid,
@ -213,6 +213,7 @@ export const subscribeResolver = authorized<
scheduledDates: [new Date()], // fetch immediately
fetchedDates: [updatedSubscription.lastFetchedAt || null],
checksums: [updatedSubscription.lastFetchedChecksum || null],
addToLibraryFlags: [!!updatedSubscription.autoAddToLibrary],
})
return {
@ -227,18 +228,20 @@ export const subscribeResolver = authorized<
// limit number of rss subscriptions to 150
const results = (await getRepository(Subscription).query(
`insert into omnivore.subscriptions (name, url, description, type, user_id, icon)
select $1, $2, $3, $4, $5, $6 from omnivore.subscriptions
`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
where user_id = $5 and type = 'RSS' and status = 'ACTIVE'
having count(*) < $7
having count(*) < $9
returning *;`,
[
feed.title,
input.url,
feed.feedUrl,
feed.description || null,
SubscriptionType.Rss,
uid,
feed.image?.url || null,
input.autoAddToLibrary ?? null,
input.isPrivate ?? null,
MAX_RSS_SUBSCRIPTIONS,
]
)) as Subscription[]
@ -259,6 +262,7 @@ export const subscribeResolver = authorized<
scheduledDates: [new Date()], // fetch immediately
fetchedDates: [null],
checksums: [null],
addToLibraryFlags: [!!newSubscription.autoAddToLibrary],
})
return {
@ -312,6 +316,8 @@ export const updateSubscriptionResolver = authorized<
scheduledAt: input.scheduledAt
? new Date(input.scheduledAt)
: undefined,
autoAddToLibrary: input.autoAddToLibrary ?? undefined,
isPrivate: input.isPrivate ?? undefined,
})
return repo.findOneByOrFail({

View file

@ -1,4 +1,3 @@
import { LibraryItemState } from '../../entity/library_item'
import {
MutationUpdatePageArgs,
UpdatePageError,
@ -21,9 +20,6 @@ export const updatePageResolver = authorized<
savedAt: input.savedAt ? new Date(input.savedAt) : undefined,
publishedAt: input.publishedAt ? new Date(input.publishedAt) : undefined,
thumbnail: input.previewImage ?? undefined,
state: input.state
? (input.state as unknown as LibraryItemState)
: undefined,
},
uid
)

View file

@ -0,0 +1,71 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import express from 'express'
import { saveFeedItemInFollowing } from '../../services/library_item'
import { logger } from '../../utils/logger'
type SourceOfFollowing = 'feed' | 'newsletter' | 'user'
export interface SaveFollowingItemRequest {
userIds: string[]
title: string
url: string
itemId: string
addedToFollowingBy: string
addedToFollowingFrom: SourceOfFollowing
author?: string
description?: string
links?: any
previewContent?: string
previewContentType?: string
publishedAt?: Date
savedAt: Date
}
function isSaveFollowingItemRequest(
body: any
): body is SaveFollowingItemRequest {
return (
'userIds' in body &&
'addedToFollowingBy' in body &&
'addedToFollowingFrom' in body &&
'url' in body &&
'itemId' in body &&
'title' in body &&
'savedAt' in body
)
}
export function followingServiceRouter() {
const router = express.Router()
router.post('/save', async (req, res) => {
logger.info('save following item request', req.body)
if (req.query.token !== process.env.PUBSUB_VERIFICATION_TOKEN) {
console.log('query does not include valid token')
return res.sendStatus(403)
}
if (!isSaveFollowingItemRequest(req.body)) {
console.error('Invalid request body', req.body)
return res.status(400).send('INVALID_REQUEST_BODY')
}
if (req.body.addedToFollowingFrom === 'feed') {
logger.info('saving feed item')
const result = await saveFeedItemInFollowing(req.body)
if (result.identifiers.length === 0) {
logger.error('error saving feed item in following')
return res.status(500).send('ERROR_SAVING_FEED_ITEM')
}
logger.info('feed item saved in following')
return res.sendStatus(200)
}
res.sendStatus(200)
})
return router
}

View file

@ -34,7 +34,8 @@ export function rssFeedRouter() {
ARRAY_AGG(user_id) AS "userIds",
ARRAY_AGG(last_fetched_at) AS "fetchedDates",
ARRAY_AGG(coalesce(scheduled_at, NOW())) AS "scheduledDates",
ARRAY_AGG(last_fetched_checksum) AS checksums
ARRAY_AGG(last_fetched_checksum) AS checksums,
ARRAY_AGG(coalesce(auto_add_to_library, false)) AS "addToLibraryFlags"
FROM
omnivore.subscriptions
WHERE

View file

@ -1658,6 +1658,8 @@ const schema = gql`
lastFetchedAt: Date
createdAt: Date!
updatedAt: Date
isPrivate: Boolean
autoAddToLibrary: Boolean
}
enum SubscriptionStatus {
@ -2554,6 +2556,8 @@ const schema = gql`
input SubscribeInput {
url: String!
subscriptionType: SubscriptionType
isPrivate: Boolean
autoAddToLibrary: Boolean
}
input UpdateSubscriptionInput {
@ -2564,6 +2568,8 @@ const schema = gql`
lastFetchedChecksum: String
status: SubscriptionStatus
scheduledAt: Date
isPrivate: Boolean
autoAddToLibrary: Boolean
}
union UpdateSubscriptionResult =
@ -2605,6 +2611,62 @@ const schema = gql`
email: String!
}
input FeedsInput {
after: String
first: Int
query: String @sanitize(maxLength: 255)
sort: SortParams
}
union FeedsResult = FeedsSuccess | FeedsError
type FeedsSuccess {
edges: [FeedEdge!]!
pageInfo: PageInfo!
}
type FeedEdge {
cursor: String!
node: Feed!
}
type FeedsError {
errorCodes: [FeedsErrorCode!]!
}
enum FeedsErrorCode {
UNAUTHORIZED
BAD_REQUEST
}
type Feed {
id: ID!
title: String!
url: String!
description: String
image: String
createdAt: Date!
updatedAt: Date!
publishedAt: Date
author: String
}
union MoveToFolderResult = MoveToFolderSuccess | MoveToFolderError
type MoveToFolderSuccess {
articleSavingRequest: ArticleSavingRequest!
}
type MoveToFolderError {
errorCodes: [MoveToFolderErrorCode!]!
}
enum MoveToFolderErrorCode {
UNAUTHORIZED
BAD_REQUEST
ALREADY_EXISTS
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -2708,6 +2770,7 @@ const schema = gql`
updateSubscription(
input: UpdateSubscriptionInput!
): UpdateSubscriptionResult!
moveToFolder(id: ID!, folder: String!): MoveToFolderResult!
}
# FIXME: remove sort from feedArticles after all cached tabs are closed
@ -2766,6 +2829,7 @@ const schema = gql`
filters: FiltersResult!
groups: GroupsResult!
recentEmails: RecentEmailsResult!
feeds(input: FeedsInput!): FeedsResult!
}
`

View file

@ -25,6 +25,7 @@ import { pageRouter } from './routers/page_router'
import { contentServiceRouter } from './routers/svc/content'
import { emailsServiceRouter } from './routers/svc/emails'
import { emailAttachmentRouter } from './routers/svc/email_attachment'
import { followingServiceRouter } from './routers/svc/following'
import { integrationsServiceRouter } from './routers/svc/integrations'
import { linkServiceRouter } from './routers/svc/links'
import { newsletterServiceRouter } from './routers/svc/newsletters'
@ -125,6 +126,7 @@ export const createApp = (): {
app.use('/svc/pubsub/user', userServiceRouter())
// app.use('/svc/reminders', remindersServiceRouter())
app.use('/svc/email-attachment', emailAttachmentRouter())
app.use('/svc/following', followingServiceRouter())
if (env.dev.isLocal) {
app.use('/local/debug', localDebugRouter())

View file

@ -130,7 +130,7 @@ export const createPageSaveRequest = async ({
pubsub
)
}
// reset state to processing
// reset state to processing if not in following
if (libraryItem.state !== LibraryItemState.Processing) {
libraryItem = await updateLibraryItem(
libraryItem.id,

View file

@ -159,7 +159,7 @@ const createDefaultFiltersForUser =
name: 'Continue Reading',
filter: 'in:inbox sort:read-desc is:reading',
},
{ name: 'Non-Feed Items', filter: 'in:library' },
{ name: 'Non-Feed Items', filter: 'no:subscription' },
{ name: 'Highlights', filter: 'has:highlights mode:highlights' },
{ name: 'Unlabeled', filter: 'no:label' },
{ name: 'Oldest First', filter: 'sort:saved-asc' },

View file

@ -1,55 +1,7 @@
import axios from 'axios'
import { LibraryItemState } from '../../entity/library_item'
import { env } from '../../env'
import { logger } from '../../utils/logger'
import {
IntegrationClient,
RetrievedResult,
RetrieveRequest,
} from './integration'
interface PocketResponse {
status: number // 1 if success
complete: number // 1 if all items have been returned
list: {
[key: string]: PocketItem
}
since: number // unix timestamp in seconds
search_meta: {
search_type: string
}
error: string
}
interface PocketItem {
item_id: string
resolved_id: string
given_url: string
resolved_url: string
given_title: string
resolved_title: string
favorite: string
status: string
excerpt: string
word_count: string
tags?: {
[key: string]: Tag
}
authors?: {
[key: string]: Author
}
}
interface Tag {
item_id: string
tag: string
}
interface Author {
item_id: string
author_id: string
name: string
}
import { IntegrationClient } from './integration'
export class PocketClient implements IntegrationClient {
name = 'POCKET'
@ -83,82 +35,4 @@ export class PocketClient implements IntegrationClient {
return null
}
}
retrievePocketData = async (
accessToken: string,
since: number, // unix timestamp in seconds
count = 100,
offset = 0
): Promise<PocketResponse | null> => {
const url = `${this.apiUrl}/get`
try {
const response = await axios.post<PocketResponse>(
url,
{
consumer_key: env.pocket.consumerKey,
access_token: accessToken,
state: 'all',
detailType: 'complete',
since,
sort: 'oldest',
count,
offset,
},
{
headers: this.headers,
timeout: 10000, // 10 seconds
}
)
return response.data
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error(error.response)
} else {
logger.error(error)
}
return null
}
}
retrieve = async ({
token,
since = 0,
count = 100,
offset = 0,
}: RetrieveRequest): Promise<RetrievedResult> => {
const pocketData = await this.retrievePocketData(
token,
since / 1000,
count,
offset
)
if (!pocketData) {
throw new Error('Error retrieving pocket data')
}
const pocketItems = Object.values(pocketData.list)
const statusToState: Record<string, LibraryItemState> = {
'0': LibraryItemState.Succeeded,
'1': LibraryItemState.Archived,
'2': LibraryItemState.Deleted,
}
const data = pocketItems.map((item) => ({
url: item.given_url,
labels: item.tags
? Object.values(item.tags).map((tag) => tag.tag)
: undefined,
state: statusToState[item.status],
}))
if (pocketData.error) {
throw new Error(`Error retrieving pocket data: ${pocketData.error}`)
}
return {
data,
since: pocketData.since * 1000,
}
}
}

View file

@ -2,33 +2,6 @@ import axios from 'axios'
import { logger } from '../../utils/logger'
import { IntegrationClient } from './integration'
interface ReadwiseHighlight {
// The highlight text, (technically the only field required in a highlight object)
text: string
// The title of the page the highlight is on
title?: string
// The author of the page the highlight is on
author?: string
// The URL of the page image
image_url?: string
// The URL of the page
source_url?: string
// A meaningful unique identifier for your app
source_type?: string
// One of: books, articles, tweets or podcasts
category?: string
// Annotation note attached to the specific highlight
note?: string
// Highlight's location in the source text. Used to order the highlights
location?: number
// One of: page, order or time_offset
location_type?: string
// A datetime representing when the highlight was taken in the ISO 8601 format
highlighted_at?: string
// Unique url of the specific highlight
highlight_url?: string
}
export class ReadwiseClient implements IntegrationClient {
name = 'READWISE'
apiUrl = 'https://readwise.io/api/v2'

View file

@ -8,6 +8,8 @@ import { BulkActionType } from '../generated/graphql'
import { createPubSubClient, EntityType } from '../pubsub'
import { authTrx, getColumns } from '../repository'
import { libraryItemRepository } from '../repository/library_item'
import { SaveFollowingItemRequest } from '../routers/svc/following'
import { SetClaimsRole } from '../utils/dictionary'
import { wordsCount } from '../utils/helpers'
import {
DateFilter,
@ -29,7 +31,7 @@ export interface SearchArgs {
size?: number
sort?: Sort
query?: string
inFilter?: InFilter
inFilter: InFilter
readFilter?: ReadFilter
typeFilter?: string
labelFilters?: LabelFilter[]
@ -62,7 +64,6 @@ export interface SearchResultItem {
title: string
uploadFileId?: string | null
url: string
archivedAt?: Date | null
readingProgressTopPercent?: number
readingProgressPercent: number
readingProgressAnchorIndex: number
@ -104,33 +105,9 @@ const buildWhereClause = (
}
if (args.inFilter !== InFilter.ALL) {
switch (args.inFilter) {
case InFilter.INBOX:
queryBuilder.andWhere('library_item.archived_at IS NULL')
break
case InFilter.ARCHIVE:
queryBuilder.andWhere('library_item.archived_at IS NOT NULL')
break
case InFilter.TRASH:
// return only deleted pages within 14 days
queryBuilder.andWhere(
"library_item.deleted_at >= now() - interval '14 days'"
)
break
case InFilter.SUBSCRIPTION:
queryBuilder
.andWhere("NOT ('library' ILIKE ANY (library_item.label_names))")
.andWhere('library_item.archived_at IS NULL')
.andWhere('library_item.subscription IS NOT NULL')
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')
break
}
queryBuilder.andWhere('library_item.folder = :folder', {
folder: args.inFilter,
})
}
if (args.readFilter !== ReadFilter.ALL) {
@ -162,6 +139,8 @@ const buildWhereClause = (
case HasFilter.LABELS:
queryBuilder.andWhere("library_item.label_names <> '{}'")
break
case HasFilter.SUBSCRIPTIONS:
queryBuilder.andWhere('library_item.subscription is NOT NULL')
}
})
}
@ -244,20 +223,18 @@ const buildWhereClause = (
}
if (!args.includePending) {
queryBuilder.andWhere('library_item.state <> :state', {
state: LibraryItemState.Processing,
})
queryBuilder.andWhere("library_item.state <> 'PROCESSING'")
}
if (!args.includeDeleted && args.inFilter !== InFilter.TRASH) {
queryBuilder.andWhere('library_item.state <> :state', {
state: LibraryItemState.Deleted,
})
queryBuilder.andWhere("library_item.folder <> 'trash'")
}
if (args.noFilters) {
args.noFilters.forEach((filter) => {
queryBuilder.andWhere(`library_item.${filter.field} = '{}'`)
queryBuilder.andWhere(
`library_item.${filter.field} = '{}' OR library_item.${filter.field} IS NULL`
)
})
}
@ -318,7 +295,7 @@ export const searchLibraryItems = async (
const queryBuilder = tx
.createQueryBuilder(LibraryItem, 'library_item')
.select(selectColumns)
.where({ user: { id: userId } })
.where('library_item.user_id = :userId', { userId })
// build the where clause
buildWhereClause(queryBuilder, args)
@ -388,8 +365,6 @@ export const restoreLibraryItem = async (
{
state: LibraryItemState.Succeeded,
savedAt: new Date(),
archivedAt: null,
deletedAt: null,
},
userId,
pubsub
@ -405,22 +380,6 @@ export const updateLibraryItem = async (
const updatedLibraryItem = await authTrx(
async (tx) => {
const itemRepo = tx.withRepository(libraryItemRepository)
// reset deletedAt and archivedAt
switch (libraryItem.state) {
case LibraryItemState.Archived:
libraryItem.archivedAt = new Date()
break
case LibraryItemState.Deleted:
libraryItem.deletedAt = new Date()
break
case LibraryItemState.Processing:
case LibraryItemState.Succeeded:
libraryItem.archivedAt = null
libraryItem.deletedAt = null
break
}
await itemRepo.update(id, libraryItem)
return itemRepo.findOneByOrFail({ id })
@ -560,6 +519,33 @@ export const createLibraryItem = async (
return newLibraryItem
}
export const saveFeedItemInFollowing = (input: SaveFollowingItemRequest) => {
return authTrx(
async (tx) => {
const libraryItems: QueryDeepPartialEntity<LibraryItem>[] =
input.userIds.map((userId) => ({
...input,
user: { id: userId },
originalUrl: input.url,
subscription: input.addedToFollowingBy,
folder: InFilter.FOLLOWING,
}))
return tx
.getRepository(LibraryItem)
.createQueryBuilder()
.insert()
.values(libraryItems)
.orIgnore() // ignore if the item already exists
.returning('*')
.execute()
},
undefined,
undefined,
SetClaimsRole.ADMIN
)
}
export const findLibraryItemsByPrefix = async (
prefix: string,
userId: string,
@ -613,14 +599,14 @@ export const updateLibraryItems = async (
switch (action) {
case BulkActionType.Archive:
values = {
archivedAt: new Date(),
state: LibraryItemState.Archived,
folder: InFilter.ARCHIVE,
savedAt: new Date(),
}
break
case BulkActionType.Delete:
values = {
deletedAt: new Date(),
state: LibraryItemState.Deleted,
savedAt: new Date(),
folder: InFilter.TRASH,
}
break
case BulkActionType.AddLabels:

View file

@ -1,7 +1,12 @@
import { LibraryItemState } from '../entity/library_item'
import { User } from '../entity/user'
import { homePageURL } from '../env'
import { SaveErrorCode, SaveFileInput, SaveResult } from '../generated/graphql'
import {
ArticleSavingRequestStatus,
SaveErrorCode,
SaveFileInput,
SaveResult,
} from '../generated/graphql'
import { getStorageFileDetails } from '../utils/uploads'
import { findOrCreateLabels, saveLabelsInLibraryItem } from './labels'
import { updateLibraryItem } from './library_item'
@ -32,9 +37,11 @@ export const saveFile = async (
await updateLibraryItem(
input.clientRequestId,
{
state: input.state
? (input.state as unknown as LibraryItemState)
: LibraryItemState.Succeeded,
state: LibraryItemState.Succeeded,
folder:
input.state === ArticleSavingRequestStatus.Archived
? 'archive'
: 'inbox',
},
user.id
)

View file

@ -261,9 +261,6 @@ export const parsedContentToLibraryItem = ({
uploadFileId: uploadFileId || undefined,
readingProgressTopPercent: 0,
readingProgressHighestReadAnchor: 0,
state: state
? (state as unknown as LibraryItemState)
: LibraryItemState.Succeeded,
createdAt: validatedDate(saveTime),
savedAt: validatedDate(saveTime),
siteName: parsedContent?.siteName,
@ -272,7 +269,7 @@ export const parsedContentToLibraryItem = ({
wordCount: wordsCount(parsedContent?.textContent || ''),
contentReader: contentReaderForLibraryItem(itemType, uploadFileId),
subscription: rssFeedUrl,
archivedAt:
state === ArticleSavingRequestStatus.Archived ? new Date() : undefined,
folder: state === ArticleSavingRequestStatus.Archived ? 'archive' : 'inbox',
state: LibraryItemState.Succeeded,
}
}

View file

@ -615,6 +615,7 @@ export interface RssSubscriptionGroup {
fetchedDates: (Date | null)[]
scheduledDates: Date[]
checksums: (string | null)[]
addToLibraryFlags: boolean[]
}
export const enqueueRssFeedFetch = async (
@ -632,6 +633,7 @@ export const enqueueRssFeedFetch = async (
timestamp.getTime()
), // unix timestamp in milliseconds
userIds: subscriptionGroup.userIds,
addToLibraryFlags: subscriptionGroup.addToLibraryFlags,
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment

View file

@ -30,6 +30,7 @@ import { validateUrl } from '../services/create_page_save_request'
import { updateLibraryItem } from '../services/library_item'
import { Merge } from '../util'
import { logger } from './logger'
import { InFilter } from './search'
interface InputObject {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any
@ -239,7 +240,7 @@ export const libraryItemToArticle = (item: LibraryItem): Article => ({
state: item.state as unknown as ArticleSavingRequestStatus,
content: item.readableContent,
hash: item.textContentHash || '',
isArchived: !!item.archivedAt,
isArchived: item.folder === InFilter.ARCHIVE,
recommendations: item.recommendations?.map(
recommandationDataToRecommendation
),
@ -258,7 +259,7 @@ export const libraryItemToSearchItem = (item: LibraryItem): SearchItem => ({
url: item.originalUrl,
state: item.state as unknown as ArticleSavingRequestStatus,
content: item.readableContent,
isArchived: !!item.archivedAt,
isArchived: item.folder === InFilter.ARCHIVE,
pageType: item.itemType as unknown as PageType,
readingProgressPercent: item.readingProgressBottomPercent,
contentReader: item.contentReader as unknown as ContentReader,

View file

@ -3,6 +3,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { DateTime } from 'luxon'
import {
ISearchParserDictionary,
parse,
@ -19,12 +20,11 @@ export enum ReadFilter {
}
export enum InFilter {
ALL,
INBOX,
ARCHIVE,
TRASH,
SUBSCRIPTION,
LIBRARY,
ALL = 'all',
INBOX = 'inbox',
ARCHIVE = 'archive',
TRASH = 'trash',
FOLLOWING = 'following',
}
export interface SearchFilter {
@ -57,6 +57,7 @@ export type LabelFilter = {
export enum HasFilter {
HIGHLIGHTS,
LABELS,
SUBSCRIPTIONS,
}
export interface DateFilter {
@ -132,10 +133,8 @@ const parseInFilter = (
return InFilter.ARCHIVE
case 'TRASH':
return InFilter.TRASH
case 'SUBSCRIPTION':
return InFilter.SUBSCRIPTION
case 'LIBRARY':
return InFilter.LIBRARY
case 'FOLLOWING':
return InFilter.FOLLOWING
}
return query ? InFilter.ALL : InFilter.INBOX
@ -232,6 +231,8 @@ const parseHasFilter = (str?: string): HasFilter | undefined => {
return HasFilter.HIGHLIGHTS
case 'LABELS':
return HasFilter.LABELS
case 'SUBSCRIPTIONS':
return HasFilter.SUBSCRIPTIONS
}
}
@ -243,21 +244,49 @@ const parseDateFilter = (
return undefined
}
switch (field.toLowerCase()) {
case 'published':
field = 'published_at'
break
case 'saved':
field = 'saved_at'
break
case 'updated':
field = 'updated_at'
}
// check for special date filters
switch (str.toLowerCase()) {
case 'today':
return {
field,
startDate: DateTime.local().startOf('day').toJSDate(),
}
case 'yesterday': {
const yesterday = DateTime.local().minus({ days: 1 })
return {
field,
startDate: yesterday.startOf('day').toJSDate(),
endDate: yesterday.endOf('day').toJSDate(),
}
}
case 'this week':
return {
field,
startDate: DateTime.local().startOf('week').toJSDate(),
}
case 'this month':
return {
field,
startDate: DateTime.local().startOf('month').toJSDate(),
}
}
// check for date ranges
const [start, end] = str.split('..')
const startDate = start && start !== '*' ? new Date(start) : undefined
const endDate = end && end !== '*' ? new Date(end) : undefined
switch (field.toUpperCase()) {
case 'PUBLISHED':
field = 'published_at'
break
case 'SAVED':
field = 'saved_at'
break
case 'UPDATED':
field = 'updated_at'
}
return {
field,
startDate,
@ -357,13 +386,20 @@ const parseNoFilter = (str?: string): NoFilter | undefined => {
return { field: 'highlight_annotations' }
case 'label':
return { field: 'label_names' }
case 'subscription':
return { field: 'subscription' }
}
return undefined
}
export const parseSearchQuery = (query: string | undefined): SearchFilter => {
const searchQuery = query ? query.replace(/\W\s":/g, '') : undefined
const searchQuery = query
? query
.replace(/\W\s":/g, '')
.replace('in:subscription', 'has:subscriptions') // compatibility with old search
.replace('in:library', 'no:subscription') // compatibility with old search
: undefined
const result: SearchFilter = {
query: searchQuery,
readFilter: ReadFilter.ALL,
@ -379,19 +415,7 @@ export const parseSearchQuery = (query: string | undefined): SearchFilter => {
}
if (!searchQuery) {
return {
query: undefined,
inFilter: InFilter.INBOX,
readFilter: ReadFilter.ALL,
labelFilters: [],
hasFilters: [],
dateFilters: [],
termFilters: [],
matchFilters: [],
ids: [],
noFilters: [],
rangeFilters: [],
}
return result
}
const parsed = parse(searchQuery, {

View file

@ -410,8 +410,7 @@ describe('Article API', () => {
title,
user: { id: user.id },
originalUrl: url,
archivedAt: new Date(),
state: LibraryItemState.Archived,
folder: 'archive',
},
user.id
)
@ -609,7 +608,7 @@ describe('Article API', () => {
).expect(200)
const savedItem = await findLibraryItemByUrl(url, user.id)
expect(savedItem?.archivedAt).to.not.be.null
expect(savedItem?.folder).to.eql('archive')
expect(savedItem?.labels?.map((l) => l.name)).to.eql(labels)
})
})
@ -692,7 +691,7 @@ describe('Article API', () => {
200
)
const item = await findLibraryItemById(itemId, user.id)
expect(item?.state).to.eql(LibraryItemState.Deleted)
expect(item?.folder).to.eql('trash')
})
})
@ -1031,8 +1030,7 @@ describe('Article API', () => {
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
archivedAt: new Date(),
state: LibraryItemState.Archived,
folder: 'archive',
},
{
user,
@ -1040,8 +1038,7 @@ describe('Article API', () => {
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
archivedAt: new Date(),
state: LibraryItemState.Archived,
folder: 'archive',
},
{
user,
@ -1153,12 +1150,12 @@ describe('Article API', () => {
})
})
context("when in:library label:test' is in the query", () => {
context("when in:inbox label:test' is in the query", () => {
let items: LibraryItem[] = []
let label: Label
before(async () => {
keyword = 'in:library label:test'
keyword = 'in:inbox label:test'
// Create some test items
label = await createLabel('test', '', user.id)
items = await createLibraryItems(
@ -1170,29 +1167,19 @@ describe('Article API', () => {
slug: 'test slug 1',
originalUrl: `${url}/test1`,
},
{
user,
title: 'test title 2',
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
subscription: 'test subscription',
},
{
user,
title: 'test title 3',
readableContent: '<p>test 3</p>',
slug: 'test slug 3',
originalUrl: `${url}/test3`,
archivedAt: new Date(),
state: LibraryItemState.Archived,
folder: 'archive',
},
],
user.id
)
await saveLabelsInLibraryItem([label], items[0].id, user.id)
await saveLabelsInLibraryItem([label], items[1].id, user.id)
await saveLabelsInLibraryItem([label], items[2].id, user.id)
})
after(async () => {
@ -1276,7 +1263,7 @@ describe('Article API', () => {
slug: 'test slug 1',
originalUrl: `${url}/test1`,
itemType: PageType.File,
archivedAt: new Date(),
folder: 'archive',
},
{
user,
@ -1284,7 +1271,7 @@ describe('Article API', () => {
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
archivedAt: new Date(),
folder: 'archive',
readingProgressBottomPercent: 100,
},
{
@ -1325,8 +1312,8 @@ describe('Article API', () => {
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
archivedAt: new Date(),
subscription: 'feed',
folder: 'archive',
},
{
user,
@ -1342,7 +1329,7 @@ describe('Article API', () => {
readableContent: '<p>test 3</p>',
slug: 'test slug 3',
originalUrl: `${url}/test3`,
archivedAt: new Date(),
folder: 'archive',
},
],
user.id
@ -1375,7 +1362,7 @@ describe('Article API', () => {
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
deletedAt: new Date(),
folder: 'trash',
},
{
user,
@ -1383,8 +1370,8 @@ describe('Article API', () => {
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
deletedAt: new Date(),
readingProgressBottomPercent: 100,
folder: 'trash',
},
{
user,
@ -1746,7 +1733,7 @@ describe('Article API', () => {
for (let i = 0; i < 3; i++) {
await updateLibraryItem(
items[i].id,
{ state: LibraryItemState.Deleted, deletedAt: new Date() },
{ folder: 'trash', savedAt: new Date() },
user.id
)
deletedItems.push(items[i])

View file

@ -332,7 +332,9 @@ describe('Subscriptions API', () => {
const updatedSubscription = await getRepository(Subscription).findOneBy({
id: subscription.id,
})
expect(updatedSubscription?.status).to.eql(SubscriptionStatus.Unsubscribed)
expect(updatedSubscription?.status).to.eql(
SubscriptionStatus.Unsubscribed
)
// check if the email was sent
expect(fake).to.have.been.calledOnceWith({
@ -371,10 +373,15 @@ describe('Subscriptions API', () => {
before(async () => {
// fake rss parser
sinon.replace(Parser.prototype, 'parseURL', sinon.fake.resolves({
title: 'RSS Feed',
description: 'RSS Feed Description',
}))
sinon.replace(
Parser.prototype,
'parseURL',
sinon.fake.resolves({
title: 'RSS Feed',
description: 'RSS Feed Description',
feedUrl: url,
})
)
})
after(() => {
@ -439,11 +446,9 @@ describe('Subscriptions API', () => {
})
it('creates a rss subscription', async () => {
const res = await graphqlRequest(
query,
authToken,
{ input: { url, subscriptionType } },
).expect(200)
const res = await graphqlRequest(query, authToken, {
input: { url, subscriptionType },
}).expect(200)
expect(res.body.data.subscribe.subscriptions).to.have.lengthOf(1)
expect(res.body.data.subscribe.subscriptions[0].id).to.be.a('string')

View file

@ -15,6 +15,7 @@ import {
generateVerificationToken,
hashPassword,
} from '../../src/utils/auth'
import { InFilter } from '../../src/utils/search'
import * as util from '../../src/utils/sendEmail'
import { createTestUser } from '../db'
import { generateFakeUuid, request } from '../util'
@ -607,7 +608,10 @@ describe('auth router', () => {
'web'
).expect(200)
const user = await userRepository.findOneByOrFail({ name })
const { count } = await searchLibraryItems({}, user.id)
const { count } = await searchLibraryItems(
{ inFilter: InFilter.ALL },
user.id
)
expect(count).to.eql(3)
})
@ -628,7 +632,10 @@ describe('auth router', () => {
'ios'
).expect(200)
const user = await userRepository.findOneByOrFail({ name })
const { count } = await searchLibraryItems({}, user.id)
const { count } = await searchLibraryItems(
{ inFilter: InFilter.ALL },
user.id
)
expect(count).to.eql(4)
})

View file

@ -0,0 +1,25 @@
-- Type: DO
-- Name: following
-- Description: Create tables for following feature
BEGIN;
ALTER TABLE omnivore.subscriptions
ADD COLUMN is_private boolean,
ADD COLUMN auto_add_to_library boolean;
ALTER TABLE omnivore.library_item
ADD COLUMN links jsonb,
ADD COLUMN preview_content text,
ADD COLUMN preview_content_type text,
ADD COLUMN folder text NOT NULL DEFAULT 'inbox';
UPDATE omnivore.library_item SET folder = 'archive' WHERE archived_at IS NOT NULL;
UPDATE omnivore.library_item SET folder = 'trash' WHERE deleted_at IS NOT NULL;
CREATE POLICY library_item_admin_policy on omnivore.library_item
FOR ALL
TO omnivore_admin
USING (true);
COMMIT;

View file

@ -0,0 +1,19 @@
-- Type: UNDO
-- Name: following
-- Description: Create tables for following feature
BEGIN;
DROP policy library_item_admin_policy ON omnivore.library_item;
ALTER TABLE omnivore.library_item
DROP COLUMN links,
DROP COLUMN preview_content,
DROP COLUMN preview_content_type,
DROP COLUMN folder;
ALTER TABLE omnivore.subscriptions
DROP COLUMN is_private,
DROP COLUMN auto_add_to_library;
COMMIT;

View file

@ -5,7 +5,11 @@ import * as dotenv from 'dotenv' // see https://github.com/motdotla/dotenv#how-d
import * as jwt from 'jsonwebtoken'
import Parser, { Item } from 'rss-parser'
import { promisify } from 'util'
import { CONTENT_FETCH_URL, createCloudTask } from './task'
import {
CONTENT_FETCH_URL,
createCloudTask,
FOLLOWING_HANDLER_URL,
} from './task'
interface RssFeedRequest {
subscriptionIds: string[]
@ -14,6 +18,7 @@ interface RssFeedRequest {
scheduledTimestamps: number[] // unix timestamp in milliseconds
lastFetchedChecksums: string[]
userIds: string[]
addToLibraryFlags: boolean[]
}
// link can be a string or an object
@ -26,7 +31,8 @@ function isRssFeedRequest(body: any): body is RssFeedRequest {
'lastFetchedTimestamps' in body &&
'scheduledTimestamps' in body &&
'userIds' in body &&
'lastFetchedChecksums' in body
'lastFetchedChecksums' in body &&
'addToLibraryFlags' in body
)
}
@ -120,6 +126,19 @@ const sendUpdateSubscriptionMutation = async (
}
}
const createTask = async (
userId: string,
feedUrl: string,
item: Item,
autoAddToLibrary: boolean
) => {
if (autoAddToLibrary) {
return createSavingItemTask(userId, feedUrl, item)
}
return createFollowingTask(userId, feedUrl, item)
}
const createSavingItemTask = async (
userId: string,
feedUrl: string,
@ -149,6 +168,38 @@ const createSavingItemTask = async (
}
}
const createFollowingTask = async (
userId: string,
feedUrl: string,
item: Item
) => {
const input = {
userIds: [userId],
url: item.link,
title: item.title,
author: item.creator,
description: item.summary,
addedToFollowingFrom: 'feed',
previewContent: item.content || item.contentSnippet,
addedToFollowingBy: feedUrl,
savedAt: item.isoDate,
publishedAt: item.isoDate,
previewContentType: 'text/html', // TODO: get content type from feed
}
try {
console.log('Creating task', input.url)
// save page
const task = await createCloudTask(FOLLOWING_HANDLER_URL, input)
console.log('Created task', task)
return !!task
} catch (error) {
console.error('Error while creating task', error)
return false
}
}
dotenv.config()
Sentry.GCPFunction.init({
dsn: process.env.SENTRY_DSN,
@ -245,7 +296,21 @@ const processSubscription = async (
fetchResult: { content: string; checksum: string },
lastFetchedAt: number,
scheduledAt: number,
lastFetchedChecksum: string
lastFetchedChecksum: string,
autoAddToLibrary: boolean,
feed: {
lastBuildDate: any
'syn:updatePeriod': any
'syn:updateFrequency': any
'sy:updatePeriod': any
'sy:updateFrequency': any
} & Parser.Output<{
published: any
updated: any
created: any
link: any
links: any[]
}>
) => {
let lastItemFetchedAt: Date | null = null
let lastValidItem: Item | null = null
@ -258,8 +323,6 @@ const processSubscription = async (
// fetch feed
let itemCount = 0
const feed = await parser.parseString(fetchResult.content)
console.log('Fetched feed', feed.title, new Date())
const feedLastBuildDate = feed.lastBuildDate as string | undefined
console.log('Feed last build date', feedLastBuildDate)
@ -317,7 +380,7 @@ const processSubscription = async (
continue
}
const created = await createSavingItemTask(userId, feedUrl, item)
const created = await createTask(userId, feedUrl, item, autoAddToLibrary)
if (!created) {
console.error('Failed to create task for feed item', item.link)
continue
@ -340,7 +403,12 @@ const processSubscription = async (
}
// the feed has never been fetched, save at least the last valid item
const created = await createSavingItemTask(userId, feedUrl, lastValidItem)
const created = await createTask(
userId,
feedUrl,
lastValidItem,
autoAddToLibrary
)
if (!created) {
console.error('Failed to create task for feed item', lastValidItem.link)
throw new Error('Failed to create task for feed item')
@ -386,28 +454,29 @@ export const rssHandler = Sentry.GCPFunction.wrapHttpFunction(
scheduledTimestamps,
userIds,
lastFetchedChecksums,
addToLibraryFlags,
} = req.body
console.log('Processing feed', feedUrl)
const fetchResult = await fetchAndChecksum(feedUrl)
const feed = await parser.parseString(fetchResult.content)
console.log('Fetched feed', feed.title, new Date())
for (let i = 0; i < subscriptionIds.length; i++) {
const subscriptionId = subscriptionIds[i]
const lastFetchedAt = lastFetchedTimestamps[i]
const scheduledAt = scheduledTimestamps[i]
const userId = userIds[i]
const lastFetchedChecksum = lastFetchedChecksums[i]
await processSubscription(
subscriptionId,
userId,
feedUrl,
fetchResult,
lastFetchedAt,
scheduledAt,
lastFetchedChecksum
await Promise.all(
subscriptionIds.map((_, i) =>
processSubscription(
subscriptionIds[i],
userIds[i],
feedUrl,
fetchResult,
lastFetchedTimestamps[i],
scheduledTimestamps[i],
lastFetchedChecksums[i],
addToLibraryFlags[i],
feed
)
)
}
)
res.send('ok')
} catch (e) {

View file

@ -4,6 +4,7 @@ import { CloudTasksClient, protos } from '@google-cloud/tasks'
const cloudTask = new CloudTasksClient()
export const CONTENT_FETCH_URL = process.env.CONTENT_FETCH_GCF_URL
export const FOLLOWING_HANDLER_URL = process.env.FOLLOWING_HANDLER_URL
export const createCloudTask = async (
taskHandlerUrl: string | undefined,

View file

@ -169,7 +169,7 @@ export const EmptyLibrary = (props: EmptyLibraryProps) => {
return 'inbox'
case 'in:inbox sort:read-desc is:reading':
return 'continue'
case 'in:library':
case 'no:subscription':
return 'non-feed'
case 'has:highlights mode:highlights':
return 'highlight'
@ -181,7 +181,7 @@ export const EmptyLibrary = (props: EmptyLibraryProps) => {
return 'archive'
case 'label:RSS':
return 'feed'
case 'in:subscription':
case 'has:subscriptions':
return 'subscription'
case 'label:Newsletter':
return 'newsletter'

View file

@ -241,7 +241,7 @@ function Subscriptions(
>
{!collapsed ? (
<>
<FilterButton filterTerm="in:subscription" text="All" {...props} />
<FilterButton filterTerm="has:subscriptions" text="All" {...props} />
<FilterButton filterTerm={`label:RSS`} text="Feeds" {...props} />
<FilterButton
filterTerm={`label:Newsletter`}