Fix Linting...

This commit is contained in:
Thomas Rogers
2024-01-09 11:22:32 +01:00
parent 54ccbd47b9
commit c0373646cb
129 changed files with 11680 additions and 254 deletions

View File

@ -11,6 +11,28 @@
},
"plugins": ["@typescript-eslint"],
"rules": {
"semi": [2, "never"]
"semi": [2, "never"],
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"ignoreRestSiblings": true,
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-misused-promises": ["error", {
"checksVoidReturn": false
}],
"@typescript-eslint/no-unsafe-assignment": "warn",
"prettier/prettier": [
"warn",
{
"trailingComma": "es5"
}
],
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-return": "warn"
}
}

View File

@ -1,4 +1,5 @@
{
"semi": false,
"singleQuote": true
"singleQuote": true,
"trailingComma": "es5"
}

View File

@ -23,17 +23,16 @@
"@graphql-codegen/schema-ast": "^2.1.1",
"@graphql-codegen/typescript": "^2.1.1",
"@graphql-codegen/typescript-resolvers": "^2.1.1",
"@tsconfig/node14": "^1.0.1",
"@typescript-eslint/eslint-plugin": "^5.9.0",
"@typescript-eslint/parser": "^5.9.0",
"eslint": "^8.6.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"graphql": "^15.3.0",
"graphql-tag": "^2.11.0",
"lerna": "^7.4.1",
"prettier": "^2.5.1",
"typescript": "4.5.2"
"prettier": "^3.1.0",
"typescript": "^5.3.2"
},
"volta": {
"node": "18.16.1",

View File

@ -4,6 +4,11 @@
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-unsafe-argument": 0
"@typescript-eslint/no-unsafe-argument": 0,
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-redundant-type-constituents": "off",
"@typescript-eslint/no-unsafe-enum-comparison": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off"
}
}

View File

@ -90,7 +90,7 @@
"snake-case": "^3.0.3",
"supertest": "^6.2.2",
"ts-loader": "^9.3.0",
"typeorm": "^0.3.4",
"typeorm": "^0.3.17",
"typeorm-naming-strategies": "^4.1.0",
"underscore": "^1.13.6",
"urlsafe-base64": "^1.0.0",

View File

@ -17,6 +17,29 @@ export type Scalars = {
JSON: any;
};
export type AddDiscoverFeedError = {
__typename?: 'AddDiscoverFeedError';
errorCodes: Array<AddDiscoverFeedErrorCode>;
};
export enum AddDiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
Conflict = 'CONFLICT',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type AddDiscoverFeedInput = {
url: Scalars['String'];
};
export type AddDiscoverFeedResult = AddDiscoverFeedError | AddDiscoverFeedSuccess;
export type AddDiscoverFeedSuccess = {
__typename?: 'AddDiscoverFeedSuccess';
feed: DiscoverFeed;
};
export type AddPopularReadError = {
__typename?: 'AddPopularReadError';
errorCodes: Array<AddPopularReadErrorCode>;
@ -522,6 +545,51 @@ export type DeleteAccountSuccess = {
userID: Scalars['ID'];
};
export type DeleteDiscoverArticleError = {
__typename?: 'DeleteDiscoverArticleError';
errorCodes: Array<DeleteDiscoverArticleErrorCode>;
};
export enum DeleteDiscoverArticleErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type DeleteDiscoverArticleInput = {
discoverArticleId: Scalars['ID'];
};
export type DeleteDiscoverArticleResult = DeleteDiscoverArticleError | DeleteDiscoverArticleSuccess;
export type DeleteDiscoverArticleSuccess = {
__typename?: 'DeleteDiscoverArticleSuccess';
id: Scalars['ID'];
};
export type DeleteDiscoverFeedError = {
__typename?: 'DeleteDiscoverFeedError';
errorCodes: Array<DeleteDiscoverFeedErrorCode>;
};
export enum DeleteDiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
Conflict = 'CONFLICT',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type DeleteDiscoverFeedInput = {
feedId: Scalars['ID'];
};
export type DeleteDiscoverFeedResult = DeleteDiscoverFeedError | DeleteDiscoverFeedSuccess;
export type DeleteDiscoverFeedSuccess = {
__typename?: 'DeleteDiscoverFeedSuccess';
id: Scalars['String'];
};
export type DeleteFilterError = {
__typename?: 'DeleteFilterError';
errorCodes: Array<DeleteFilterErrorCode>;
@ -743,6 +811,79 @@ export type EmptyTrashSuccess = {
success?: Maybe<Scalars['Boolean']>;
};
export type DiscoverFeed = {
__typename?: 'DiscoverFeed';
description?: Maybe<Scalars['String']>;
id: Scalars['ID'];
image?: Maybe<Scalars['String']>;
link: Scalars['String'];
title: Scalars['String'];
type: Scalars['String'];
visibleName?: Maybe<Scalars['String']>;
};
export type DiscoverFeedArticle = {
__typename?: 'DiscoverFeedArticle';
author?: Maybe<Scalars['String']>;
description: Scalars['String'];
feed: Scalars['String'];
id: Scalars['ID'];
image?: Maybe<Scalars['String']>;
publishedDate?: Maybe<Scalars['Date']>;
savedId?: Maybe<Scalars['String']>;
savedLinkUrl?: Maybe<Scalars['String']>;
siteName?: Maybe<Scalars['String']>;
slug: Scalars['String'];
title: Scalars['String'];
url: Scalars['String'];
};
export type DiscoverFeedError = {
__typename?: 'DiscoverFeedError';
errorCodes: Array<DiscoverFeedErrorCode>;
};
export enum DiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type DiscoverFeedResult = DiscoverFeedError | DiscoverFeedSuccess;
export type DiscoverFeedSuccess = {
__typename?: 'DiscoverFeedSuccess';
feeds: Array<Maybe<DiscoverFeed>>;
};
export type DiscoverTopic = {
__typename?: 'DiscoverTopic';
description: Scalars['String'];
name: Scalars['String'];
};
export type EditDiscoverFeedError = {
__typename?: 'EditDiscoverFeedError';
errorCodes: Array<EditDiscoverFeedErrorCode>;
};
export enum EditDiscoverFeedErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type EditDiscoverFeedInput = {
feedId: Scalars['ID'];
name: Scalars['String'];
};
export type EditDiscoverFeedResult = EditDiscoverFeedError | EditDiscoverFeedSuccess;
export type EditDiscoverFeedSuccess = {
__typename?: 'EditDiscoverFeedSuccess';
id: Scalars['ID'];
};
export type Feature = {
__typename?: 'Feature';
createdAt: Scalars['Date'];
@ -909,6 +1050,41 @@ export type GenerateApiKeySuccess = {
apiKey: ApiKey;
};
export type GetDiscoverFeedArticleError = {
__typename?: 'GetDiscoverFeedArticleError';
errorCodes: Array<GetDiscoverFeedArticleErrorCode>;
};
export enum GetDiscoverFeedArticleErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type GetDiscoverFeedArticleResults = GetDiscoverFeedArticleError | GetDiscoverFeedArticleSuccess;
export type GetDiscoverFeedArticleSuccess = {
__typename?: 'GetDiscoverFeedArticleSuccess';
discoverArticles?: Maybe<Array<Maybe<DiscoverFeedArticle>>>;
pageInfo: PageInfo;
};
export type GetDiscoverTopicError = {
__typename?: 'GetDiscoverTopicError';
errorCodes: Array<GetDiscoverTopicErrorCode>;
};
export enum GetDiscoverTopicErrorCode {
Unauthorized = 'UNAUTHORIZED'
}
export type GetDiscoverTopicResults = GetDiscoverTopicError | GetDiscoverTopicSuccess;
export type GetDiscoverTopicSuccess = {
__typename?: 'GetDiscoverTopicSuccess';
discoverTopics?: Maybe<Array<DiscoverTopic>>;
};
export type GetFollowersError = {
__typename?: 'GetFollowersError';
errorCodes: Array<GetFollowersErrorCode>;
@ -1352,6 +1528,7 @@ export type MoveToFolderSuccess = {
export type Mutation = {
__typename?: 'Mutation';
addDiscoverFeed: AddDiscoverFeedResult;
addPopularRead: AddPopularReadResult;
bulkAction: BulkActionResult;
createArticle: CreateArticleResult;
@ -1361,6 +1538,8 @@ export type Mutation = {
createLabel: CreateLabelResult;
createNewsletterEmail: CreateNewsletterEmailResult;
deleteAccount: DeleteAccountResult;
deleteDiscoverArticle: DeleteDiscoverArticleResult;
deleteDiscoverFeed: DeleteDiscoverFeedResult;
deleteFilter: DeleteFilterResult;
deleteHighlight: DeleteHighlightResult;
deleteIntegration: DeleteIntegrationResult;
@ -1368,6 +1547,7 @@ export type Mutation = {
deleteNewsletterEmail: DeleteNewsletterEmailResult;
deleteRule: DeleteRuleResult;
deleteWebhook: DeleteWebhookResult;
editDiscoverFeed: EditDiscoverFeedResult;
emptyTrash: EmptyTrashResult;
fetchContent: FetchContentResult;
generateApiKey: GenerateApiKeyResult;
@ -1388,6 +1568,7 @@ export type Mutation = {
reportItem: ReportItemResult;
revokeApiKey: RevokeApiKeyResult;
saveArticleReadingProgress: SaveArticleReadingProgressResult;
saveDiscoverArticle: SaveDiscoverArticleResult;
saveFile: SaveResult;
saveFilter: SaveFilterResult;
savePage: SaveResult;
@ -1418,6 +1599,11 @@ export type Mutation = {
};
export type MutationAddDiscoverFeedArgs = {
input: AddDiscoverFeedInput;
};
export type MutationAddPopularReadArgs = {
name: Scalars['String'];
};
@ -1468,6 +1654,16 @@ export type MutationDeleteAccountArgs = {
};
export type MutationDeleteDiscoverArticleArgs = {
input: DeleteDiscoverArticleInput;
};
export type MutationDeleteDiscoverFeedArgs = {
input: DeleteDiscoverFeedInput;
};
export type MutationDeleteFilterArgs = {
id: Scalars['ID'];
};
@ -1503,6 +1699,11 @@ export type MutationDeleteWebhookArgs = {
};
export type MutationEditDiscoverFeedArgs = {
input: EditDiscoverFeedInput;
};
export type MutationFetchContentArgs = {
id: Scalars['ID'];
};
@ -1594,6 +1795,11 @@ export type MutationSaveArticleReadingProgressArgs = {
};
export type MutationSaveDiscoverArticleArgs = {
input: SaveDiscoverArticleInput;
};
export type MutationSaveFileArgs = {
input: SaveFileInput;
};
@ -1865,8 +2071,11 @@ export type Query = {
article: ArticleResult;
articleSavingRequest: ArticleSavingRequestResult;
deviceTokens: DeviceTokensResult;
discoverFeeds: DiscoverFeedResult;
discoverTopics: GetDiscoverTopicResults;
feeds: FeedsResult;
filters: FiltersResult;
getDiscoverFeedArticles: GetDiscoverFeedArticleResults;
getUserPersonalization: GetUserPersonalizationResult;
groups: GroupsResult;
hello?: Maybe<Scalars['String']>;
@ -1909,6 +2118,14 @@ export type QueryFeedsArgs = {
};
export type QueryGetDiscoverFeedArticlesArgs = {
after?: InputMaybe<Scalars['String']>;
discoverTopicId: Scalars['String'];
feedId?: InputMaybe<Scalars['ID']>;
first?: InputMaybe<Scalars['Int']>;
};
export type QueryRulesArgs = {
enabled?: InputMaybe<Scalars['Boolean']>;
};
@ -2267,6 +2484,31 @@ export type SaveArticleReadingProgressSuccess = {
updatedArticle: Article;
};
export type SaveDiscoverArticleError = {
__typename?: 'SaveDiscoverArticleError';
errorCodes: Array<SaveDiscoverArticleErrorCode>;
};
export enum SaveDiscoverArticleErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type SaveDiscoverArticleInput = {
discoverArticleId: Scalars['ID'];
locale?: InputMaybe<Scalars['String']>;
timezone?: InputMaybe<Scalars['String']>;
};
export type SaveDiscoverArticleResult = SaveDiscoverArticleError | SaveDiscoverArticleSuccess;
export type SaveDiscoverArticleSuccess = {
__typename?: 'SaveDiscoverArticleSuccess';
saveId: Scalars['String'];
url: Scalars['String'];
};
export type SaveError = {
__typename?: 'SaveError';
errorCodes: Array<SaveErrorCode>;
@ -3547,6 +3789,11 @@ export type DirectiveResolverFn<TResult = {}, TParent = {}, TContext = {}, TArgs
/** Mapping between all available schema types and the resolvers types */
export type ResolversTypes = {
AddDiscoverFeedError: ResolverTypeWrapper<AddDiscoverFeedError>;
AddDiscoverFeedErrorCode: AddDiscoverFeedErrorCode;
AddDiscoverFeedInput: AddDiscoverFeedInput;
AddDiscoverFeedResult: ResolversTypes['AddDiscoverFeedError'] | ResolversTypes['AddDiscoverFeedSuccess'];
AddDiscoverFeedSuccess: ResolverTypeWrapper<AddDiscoverFeedSuccess>;
AddPopularReadError: ResolverTypeWrapper<AddPopularReadError>;
AddPopularReadErrorCode: AddPopularReadErrorCode;
AddPopularReadResult: ResolversTypes['AddPopularReadError'] | ResolversTypes['AddPopularReadSuccess'];
@ -3635,6 +3882,16 @@ export type ResolversTypes = {
DeleteAccountErrorCode: DeleteAccountErrorCode;
DeleteAccountResult: ResolversTypes['DeleteAccountError'] | ResolversTypes['DeleteAccountSuccess'];
DeleteAccountSuccess: ResolverTypeWrapper<DeleteAccountSuccess>;
DeleteDiscoverArticleError: ResolverTypeWrapper<DeleteDiscoverArticleError>;
DeleteDiscoverArticleErrorCode: DeleteDiscoverArticleErrorCode;
DeleteDiscoverArticleInput: DeleteDiscoverArticleInput;
DeleteDiscoverArticleResult: ResolversTypes['DeleteDiscoverArticleError'] | ResolversTypes['DeleteDiscoverArticleSuccess'];
DeleteDiscoverArticleSuccess: ResolverTypeWrapper<DeleteDiscoverArticleSuccess>;
DeleteDiscoverFeedError: ResolverTypeWrapper<DeleteDiscoverFeedError>;
DeleteDiscoverFeedErrorCode: DeleteDiscoverFeedErrorCode;
DeleteDiscoverFeedInput: DeleteDiscoverFeedInput;
DeleteDiscoverFeedResult: ResolversTypes['DeleteDiscoverFeedError'] | ResolversTypes['DeleteDiscoverFeedSuccess'];
DeleteDiscoverFeedSuccess: ResolverTypeWrapper<DeleteDiscoverFeedSuccess>;
DeleteFilterError: ResolverTypeWrapper<DeleteFilterError>;
DeleteFilterErrorCode: DeleteFilterErrorCode;
DeleteFilterResult: ResolversTypes['DeleteFilterError'] | ResolversTypes['DeleteFilterSuccess'];
@ -3680,6 +3937,18 @@ export type ResolversTypes = {
DeviceTokensErrorCode: DeviceTokensErrorCode;
DeviceTokensResult: ResolversTypes['DeviceTokensError'] | ResolversTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: ResolverTypeWrapper<DeviceTokensSuccess>;
DiscoverFeed: ResolverTypeWrapper<DiscoverFeed>;
DiscoverFeedArticle: ResolverTypeWrapper<DiscoverFeedArticle>;
DiscoverFeedError: ResolverTypeWrapper<DiscoverFeedError>;
DiscoverFeedErrorCode: DiscoverFeedErrorCode;
DiscoverFeedResult: ResolversTypes['DiscoverFeedError'] | ResolversTypes['DiscoverFeedSuccess'];
DiscoverFeedSuccess: ResolverTypeWrapper<DiscoverFeedSuccess>;
DiscoverTopic: ResolverTypeWrapper<DiscoverTopic>;
EditDiscoverFeedError: ResolverTypeWrapper<EditDiscoverFeedError>;
EditDiscoverFeedErrorCode: EditDiscoverFeedErrorCode;
EditDiscoverFeedInput: EditDiscoverFeedInput;
EditDiscoverFeedResult: ResolversTypes['EditDiscoverFeedError'] | ResolversTypes['EditDiscoverFeedSuccess'];
EditDiscoverFeedSuccess: ResolverTypeWrapper<EditDiscoverFeedSuccess>;
EmptyTrashError: ResolverTypeWrapper<EmptyTrashError>;
EmptyTrashErrorCode: EmptyTrashErrorCode;
EmptyTrashResult: ResolversTypes['EmptyTrashError'] | ResolversTypes['EmptyTrashSuccess'];
@ -3713,6 +3982,14 @@ export type ResolversTypes = {
GenerateApiKeyInput: GenerateApiKeyInput;
GenerateApiKeyResult: ResolversTypes['GenerateApiKeyError'] | ResolversTypes['GenerateApiKeySuccess'];
GenerateApiKeySuccess: ResolverTypeWrapper<GenerateApiKeySuccess>;
GetDiscoverFeedArticleError: ResolverTypeWrapper<GetDiscoverFeedArticleError>;
GetDiscoverFeedArticleErrorCode: GetDiscoverFeedArticleErrorCode;
GetDiscoverFeedArticleResults: ResolversTypes['GetDiscoverFeedArticleError'] | ResolversTypes['GetDiscoverFeedArticleSuccess'];
GetDiscoverFeedArticleSuccess: ResolverTypeWrapper<GetDiscoverFeedArticleSuccess>;
GetDiscoverTopicError: ResolverTypeWrapper<GetDiscoverTopicError>;
GetDiscoverTopicErrorCode: GetDiscoverTopicErrorCode;
GetDiscoverTopicResults: ResolversTypes['GetDiscoverTopicError'] | ResolversTypes['GetDiscoverTopicSuccess'];
GetDiscoverTopicSuccess: ResolverTypeWrapper<GetDiscoverTopicSuccess>;
GetFollowersError: ResolverTypeWrapper<GetFollowersError>;
GetFollowersErrorCode: GetFollowersErrorCode;
GetFollowersResult: ResolversTypes['GetFollowersError'] | ResolversTypes['GetFollowersSuccess'];
@ -3869,6 +4146,11 @@ export type ResolversTypes = {
SaveArticleReadingProgressInput: SaveArticleReadingProgressInput;
SaveArticleReadingProgressResult: ResolversTypes['SaveArticleReadingProgressError'] | ResolversTypes['SaveArticleReadingProgressSuccess'];
SaveArticleReadingProgressSuccess: ResolverTypeWrapper<SaveArticleReadingProgressSuccess>;
SaveDiscoverArticleError: ResolverTypeWrapper<SaveDiscoverArticleError>;
SaveDiscoverArticleErrorCode: SaveDiscoverArticleErrorCode;
SaveDiscoverArticleInput: SaveDiscoverArticleInput;
SaveDiscoverArticleResult: ResolversTypes['SaveDiscoverArticleError'] | ResolversTypes['SaveDiscoverArticleSuccess'];
SaveDiscoverArticleSuccess: ResolverTypeWrapper<SaveDiscoverArticleSuccess>;
SaveError: ResolverTypeWrapper<SaveError>;
SaveErrorCode: SaveErrorCode;
SaveFileInput: SaveFileInput;
@ -4088,6 +4370,10 @@ export type ResolversTypes = {
/** Mapping between all available schema types and the resolvers parents */
export type ResolversParentTypes = {
AddDiscoverFeedError: AddDiscoverFeedError;
AddDiscoverFeedInput: AddDiscoverFeedInput;
AddDiscoverFeedResult: ResolversParentTypes['AddDiscoverFeedError'] | ResolversParentTypes['AddDiscoverFeedSuccess'];
AddDiscoverFeedSuccess: AddDiscoverFeedSuccess;
AddPopularReadError: AddPopularReadError;
AddPopularReadResult: ResolversParentTypes['AddPopularReadError'] | ResolversParentTypes['AddPopularReadSuccess'];
AddPopularReadSuccess: AddPopularReadSuccess;
@ -4156,6 +4442,14 @@ export type ResolversParentTypes = {
DeleteAccountError: DeleteAccountError;
DeleteAccountResult: ResolversParentTypes['DeleteAccountError'] | ResolversParentTypes['DeleteAccountSuccess'];
DeleteAccountSuccess: DeleteAccountSuccess;
DeleteDiscoverArticleError: DeleteDiscoverArticleError;
DeleteDiscoverArticleInput: DeleteDiscoverArticleInput;
DeleteDiscoverArticleResult: ResolversParentTypes['DeleteDiscoverArticleError'] | ResolversParentTypes['DeleteDiscoverArticleSuccess'];
DeleteDiscoverArticleSuccess: DeleteDiscoverArticleSuccess;
DeleteDiscoverFeedError: DeleteDiscoverFeedError;
DeleteDiscoverFeedInput: DeleteDiscoverFeedInput;
DeleteDiscoverFeedResult: ResolversParentTypes['DeleteDiscoverFeedError'] | ResolversParentTypes['DeleteDiscoverFeedSuccess'];
DeleteDiscoverFeedSuccess: DeleteDiscoverFeedSuccess;
DeleteFilterError: DeleteFilterError;
DeleteFilterResult: ResolversParentTypes['DeleteFilterError'] | ResolversParentTypes['DeleteFilterSuccess'];
DeleteFilterSuccess: DeleteFilterSuccess;
@ -4190,6 +4484,16 @@ export type ResolversParentTypes = {
DeviceTokensError: DeviceTokensError;
DeviceTokensResult: ResolversParentTypes['DeviceTokensError'] | ResolversParentTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: DeviceTokensSuccess;
DiscoverFeed: DiscoverFeed;
DiscoverFeedArticle: DiscoverFeedArticle;
DiscoverFeedError: DiscoverFeedError;
DiscoverFeedResult: ResolversParentTypes['DiscoverFeedError'] | ResolversParentTypes['DiscoverFeedSuccess'];
DiscoverFeedSuccess: DiscoverFeedSuccess;
DiscoverTopic: DiscoverTopic;
EditDiscoverFeedError: EditDiscoverFeedError;
EditDiscoverFeedInput: EditDiscoverFeedInput;
EditDiscoverFeedResult: ResolversParentTypes['EditDiscoverFeedError'] | ResolversParentTypes['EditDiscoverFeedSuccess'];
EditDiscoverFeedSuccess: EditDiscoverFeedSuccess;
EmptyTrashError: EmptyTrashError;
EmptyTrashResult: ResolversParentTypes['EmptyTrashError'] | ResolversParentTypes['EmptyTrashSuccess'];
EmptyTrashSuccess: EmptyTrashSuccess;
@ -4217,6 +4521,12 @@ export type ResolversParentTypes = {
GenerateApiKeyInput: GenerateApiKeyInput;
GenerateApiKeyResult: ResolversParentTypes['GenerateApiKeyError'] | ResolversParentTypes['GenerateApiKeySuccess'];
GenerateApiKeySuccess: GenerateApiKeySuccess;
GetDiscoverFeedArticleError: GetDiscoverFeedArticleError;
GetDiscoverFeedArticleResults: ResolversParentTypes['GetDiscoverFeedArticleError'] | ResolversParentTypes['GetDiscoverFeedArticleSuccess'];
GetDiscoverFeedArticleSuccess: GetDiscoverFeedArticleSuccess;
GetDiscoverTopicError: GetDiscoverTopicError;
GetDiscoverTopicResults: ResolversParentTypes['GetDiscoverTopicError'] | ResolversParentTypes['GetDiscoverTopicSuccess'];
GetDiscoverTopicSuccess: GetDiscoverTopicSuccess;
GetFollowersError: GetFollowersError;
GetFollowersResult: ResolversParentTypes['GetFollowersError'] | ResolversParentTypes['GetFollowersSuccess'];
GetFollowersSuccess: GetFollowersSuccess;
@ -4339,6 +4649,10 @@ export type ResolversParentTypes = {
SaveArticleReadingProgressInput: SaveArticleReadingProgressInput;
SaveArticleReadingProgressResult: ResolversParentTypes['SaveArticleReadingProgressError'] | ResolversParentTypes['SaveArticleReadingProgressSuccess'];
SaveArticleReadingProgressSuccess: SaveArticleReadingProgressSuccess;
SaveDiscoverArticleError: SaveDiscoverArticleError;
SaveDiscoverArticleInput: SaveDiscoverArticleInput;
SaveDiscoverArticleResult: ResolversParentTypes['SaveDiscoverArticleError'] | ResolversParentTypes['SaveDiscoverArticleSuccess'];
SaveDiscoverArticleSuccess: SaveDiscoverArticleSuccess;
SaveError: SaveError;
SaveFileInput: SaveFileInput;
SaveFilterError: SaveFilterError;
@ -4515,6 +4829,20 @@ export type SanitizeDirectiveArgs = {
export type SanitizeDirectiveResolver<Result, Parent, ContextType = ResolverContext, Args = SanitizeDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
export type AddDiscoverFeedErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['AddDiscoverFeedError'] = ResolversParentTypes['AddDiscoverFeedError']> = {
errorCodes?: Resolver<Array<ResolversTypes['AddDiscoverFeedErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AddDiscoverFeedResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['AddDiscoverFeedResult'] = ResolversParentTypes['AddDiscoverFeedResult']> = {
__resolveType: TypeResolveFn<'AddDiscoverFeedError' | 'AddDiscoverFeedSuccess', ParentType, ContextType>;
};
export type AddDiscoverFeedSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['AddDiscoverFeedSuccess'] = ResolversParentTypes['AddDiscoverFeedSuccess']> = {
feed?: Resolver<ResolversTypes['DiscoverFeed'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AddPopularReadErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['AddPopularReadError'] = ResolversParentTypes['AddPopularReadError']> = {
errorCodes?: Resolver<Array<ResolversTypes['AddPopularReadErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4838,6 +5166,34 @@ export type DeleteAccountSuccessResolvers<ContextType = ResolverContext, ParentT
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteDiscoverArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverArticleError'] = ResolversParentTypes['DeleteDiscoverArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteDiscoverArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteDiscoverArticleResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverArticleResult'] = ResolversParentTypes['DeleteDiscoverArticleResult']> = {
__resolveType: TypeResolveFn<'DeleteDiscoverArticleError' | 'DeleteDiscoverArticleSuccess', ParentType, ContextType>;
};
export type DeleteDiscoverArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverArticleSuccess'] = ResolversParentTypes['DeleteDiscoverArticleSuccess']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteDiscoverFeedErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverFeedError'] = ResolversParentTypes['DeleteDiscoverFeedError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteDiscoverFeedErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteDiscoverFeedResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverFeedResult'] = ResolversParentTypes['DeleteDiscoverFeedResult']> = {
__resolveType: TypeResolveFn<'DeleteDiscoverFeedError' | 'DeleteDiscoverFeedSuccess', ParentType, ContextType>;
};
export type DeleteDiscoverFeedSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteDiscoverFeedSuccess'] = ResolversParentTypes['DeleteDiscoverFeedSuccess']> = {
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DeleteFilterErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DeleteFilterError'] = ResolversParentTypes['DeleteFilterError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DeleteFilterErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4999,6 +5355,67 @@ export type DeviceTokensSuccessResolvers<ContextType = ResolverContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverFeedResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeed'] = ResolversParentTypes['DiscoverFeed']> = {
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
image?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
link?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
type?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
visibleName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverFeedArticleResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeedArticle'] = ResolversParentTypes['DiscoverFeedArticle']> = {
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
description?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
feed?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
image?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
publishedDate?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
savedId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
savedLinkUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
siteName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverFeedErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeedError'] = ResolversParentTypes['DiscoverFeedError']> = {
errorCodes?: Resolver<Array<ResolversTypes['DiscoverFeedErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverFeedResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeedResult'] = ResolversParentTypes['DiscoverFeedResult']> = {
__resolveType: TypeResolveFn<'DiscoverFeedError' | 'DiscoverFeedSuccess', ParentType, ContextType>;
};
export type DiscoverFeedSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverFeedSuccess'] = ResolversParentTypes['DiscoverFeedSuccess']> = {
feeds?: Resolver<Array<Maybe<ResolversTypes['DiscoverFeed']>>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type DiscoverTopicResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['DiscoverTopic'] = ResolversParentTypes['DiscoverTopic']> = {
description?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EditDiscoverFeedErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EditDiscoverFeedError'] = ResolversParentTypes['EditDiscoverFeedError']> = {
errorCodes?: Resolver<Array<ResolversTypes['EditDiscoverFeedErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EditDiscoverFeedResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EditDiscoverFeedResult'] = ResolversParentTypes['EditDiscoverFeedResult']> = {
__resolveType: TypeResolveFn<'EditDiscoverFeedError' | 'EditDiscoverFeedSuccess', ParentType, ContextType>;
};
export type EditDiscoverFeedSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EditDiscoverFeedSuccess'] = ResolversParentTypes['EditDiscoverFeedSuccess']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EmptyTrashErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EmptyTrashError'] = ResolversParentTypes['EmptyTrashError']> = {
errorCodes?: Resolver<Array<ResolversTypes['EmptyTrashErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -5151,6 +5568,35 @@ export type GenerateApiKeySuccessResolvers<ContextType = ResolverContext, Parent
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetDiscoverFeedArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverFeedArticleError'] = ResolversParentTypes['GetDiscoverFeedArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GetDiscoverFeedArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetDiscoverFeedArticleResultsResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverFeedArticleResults'] = ResolversParentTypes['GetDiscoverFeedArticleResults']> = {
__resolveType: TypeResolveFn<'GetDiscoverFeedArticleError' | 'GetDiscoverFeedArticleSuccess', ParentType, ContextType>;
};
export type GetDiscoverFeedArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverFeedArticleSuccess'] = ResolversParentTypes['GetDiscoverFeedArticleSuccess']> = {
discoverArticles?: Resolver<Maybe<Array<Maybe<ResolversTypes['DiscoverFeedArticle']>>>, ParentType, ContextType>;
pageInfo?: Resolver<ResolversTypes['PageInfo'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetDiscoverTopicErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverTopicError'] = ResolversParentTypes['GetDiscoverTopicError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GetDiscoverTopicErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetDiscoverTopicResultsResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverTopicResults'] = ResolversParentTypes['GetDiscoverTopicResults']> = {
__resolveType: TypeResolveFn<'GetDiscoverTopicError' | 'GetDiscoverTopicSuccess', ParentType, ContextType>;
};
export type GetDiscoverTopicSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetDiscoverTopicSuccess'] = ResolversParentTypes['GetDiscoverTopicSuccess']> = {
discoverTopics?: Resolver<Maybe<Array<ResolversTypes['DiscoverTopic']>>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GetFollowersErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GetFollowersError'] = ResolversParentTypes['GetFollowersError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GetFollowersErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -5482,6 +5928,7 @@ export type MoveToFolderSuccessResolvers<ContextType = ResolverContext, ParentTy
};
export type MutationResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = {
addDiscoverFeed?: Resolver<ResolversTypes['AddDiscoverFeedResult'], ParentType, ContextType, RequireFields<MutationAddDiscoverFeedArgs, 'input'>>;
addPopularRead?: Resolver<ResolversTypes['AddPopularReadResult'], ParentType, ContextType, RequireFields<MutationAddPopularReadArgs, 'name'>>;
bulkAction?: Resolver<ResolversTypes['BulkActionResult'], ParentType, ContextType, RequireFields<MutationBulkActionArgs, 'action' | 'query'>>;
createArticle?: Resolver<ResolversTypes['CreateArticleResult'], ParentType, ContextType, RequireFields<MutationCreateArticleArgs, 'input'>>;
@ -5491,6 +5938,8 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
createLabel?: Resolver<ResolversTypes['CreateLabelResult'], ParentType, ContextType, RequireFields<MutationCreateLabelArgs, 'input'>>;
createNewsletterEmail?: Resolver<ResolversTypes['CreateNewsletterEmailResult'], ParentType, ContextType, Partial<MutationCreateNewsletterEmailArgs>>;
deleteAccount?: Resolver<ResolversTypes['DeleteAccountResult'], ParentType, ContextType, RequireFields<MutationDeleteAccountArgs, 'userID'>>;
deleteDiscoverArticle?: Resolver<ResolversTypes['DeleteDiscoverArticleResult'], ParentType, ContextType, RequireFields<MutationDeleteDiscoverArticleArgs, 'input'>>;
deleteDiscoverFeed?: Resolver<ResolversTypes['DeleteDiscoverFeedResult'], ParentType, ContextType, RequireFields<MutationDeleteDiscoverFeedArgs, 'input'>>;
deleteFilter?: Resolver<ResolversTypes['DeleteFilterResult'], ParentType, ContextType, RequireFields<MutationDeleteFilterArgs, 'id'>>;
deleteHighlight?: Resolver<ResolversTypes['DeleteHighlightResult'], ParentType, ContextType, RequireFields<MutationDeleteHighlightArgs, 'highlightId'>>;
deleteIntegration?: Resolver<ResolversTypes['DeleteIntegrationResult'], ParentType, ContextType, RequireFields<MutationDeleteIntegrationArgs, 'id'>>;
@ -5498,6 +5947,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteRule?: Resolver<ResolversTypes['DeleteRuleResult'], ParentType, ContextType, RequireFields<MutationDeleteRuleArgs, 'id'>>;
deleteWebhook?: Resolver<ResolversTypes['DeleteWebhookResult'], ParentType, ContextType, RequireFields<MutationDeleteWebhookArgs, 'id'>>;
editDiscoverFeed?: Resolver<ResolversTypes['EditDiscoverFeedResult'], ParentType, ContextType, RequireFields<MutationEditDiscoverFeedArgs, 'input'>>;
emptyTrash?: Resolver<ResolversTypes['EmptyTrashResult'], ParentType, ContextType>;
fetchContent?: Resolver<ResolversTypes['FetchContentResult'], ParentType, ContextType, RequireFields<MutationFetchContentArgs, 'id'>>;
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
@ -5518,6 +5968,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
saveArticleReadingProgress?: Resolver<ResolversTypes['SaveArticleReadingProgressResult'], ParentType, ContextType, RequireFields<MutationSaveArticleReadingProgressArgs, 'input'>>;
saveDiscoverArticle?: Resolver<ResolversTypes['SaveDiscoverArticleResult'], ParentType, ContextType, RequireFields<MutationSaveDiscoverArticleArgs, 'input'>>;
saveFile?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSaveFileArgs, 'input'>>;
saveFilter?: Resolver<ResolversTypes['SaveFilterResult'], ParentType, ContextType, RequireFields<MutationSaveFilterArgs, 'input'>>;
savePage?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSavePageArgs, 'input'>>;
@ -5628,8 +6079,11 @@ 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>;
discoverFeeds?: Resolver<ResolversTypes['DiscoverFeedResult'], ParentType, ContextType>;
discoverTopics?: Resolver<ResolversTypes['GetDiscoverTopicResults'], ParentType, ContextType>;
feeds?: Resolver<ResolversTypes['FeedsResult'], ParentType, ContextType, RequireFields<QueryFeedsArgs, 'input'>>;
filters?: Resolver<ResolversTypes['FiltersResult'], ParentType, ContextType>;
getDiscoverFeedArticles?: Resolver<ResolversTypes['GetDiscoverFeedArticleResults'], ParentType, ContextType, RequireFields<QueryGetDiscoverFeedArticlesArgs, 'discoverTopicId'>>;
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -5864,6 +6318,21 @@ export type SaveArticleReadingProgressSuccessResolvers<ContextType = ResolverCon
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SaveDiscoverArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveDiscoverArticleError'] = ResolversParentTypes['SaveDiscoverArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SaveDiscoverArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SaveDiscoverArticleResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveDiscoverArticleResult'] = ResolversParentTypes['SaveDiscoverArticleResult']> = {
__resolveType: TypeResolveFn<'SaveDiscoverArticleError' | 'SaveDiscoverArticleSuccess', ParentType, ContextType>;
};
export type SaveDiscoverArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveDiscoverArticleSuccess'] = ResolversParentTypes['SaveDiscoverArticleSuccess']> = {
saveId?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SaveErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveError'] = ResolversParentTypes['SaveError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SaveErrorCode']>, ParentType, ContextType>;
message?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -6596,6 +7065,9 @@ export type WebhooksSuccessResolvers<ContextType = ResolverContext, ParentType e
};
export type Resolvers<ContextType = ResolverContext> = {
AddDiscoverFeedError?: AddDiscoverFeedErrorResolvers<ContextType>;
AddDiscoverFeedResult?: AddDiscoverFeedResultResolvers<ContextType>;
AddDiscoverFeedSuccess?: AddDiscoverFeedSuccessResolvers<ContextType>;
AddPopularReadError?: AddPopularReadErrorResolvers<ContextType>;
AddPopularReadResult?: AddPopularReadResultResolvers<ContextType>;
AddPopularReadSuccess?: AddPopularReadSuccessResolvers<ContextType>;
@ -6652,6 +7124,12 @@ export type Resolvers<ContextType = ResolverContext> = {
DeleteAccountError?: DeleteAccountErrorResolvers<ContextType>;
DeleteAccountResult?: DeleteAccountResultResolvers<ContextType>;
DeleteAccountSuccess?: DeleteAccountSuccessResolvers<ContextType>;
DeleteDiscoverArticleError?: DeleteDiscoverArticleErrorResolvers<ContextType>;
DeleteDiscoverArticleResult?: DeleteDiscoverArticleResultResolvers<ContextType>;
DeleteDiscoverArticleSuccess?: DeleteDiscoverArticleSuccessResolvers<ContextType>;
DeleteDiscoverFeedError?: DeleteDiscoverFeedErrorResolvers<ContextType>;
DeleteDiscoverFeedResult?: DeleteDiscoverFeedResultResolvers<ContextType>;
DeleteDiscoverFeedSuccess?: DeleteDiscoverFeedSuccessResolvers<ContextType>;
DeleteFilterError?: DeleteFilterErrorResolvers<ContextType>;
DeleteFilterResult?: DeleteFilterResultResolvers<ContextType>;
DeleteFilterSuccess?: DeleteFilterSuccessResolvers<ContextType>;
@ -6686,6 +7164,15 @@ export type Resolvers<ContextType = ResolverContext> = {
DeviceTokensError?: DeviceTokensErrorResolvers<ContextType>;
DeviceTokensResult?: DeviceTokensResultResolvers<ContextType>;
DeviceTokensSuccess?: DeviceTokensSuccessResolvers<ContextType>;
DiscoverFeed?: DiscoverFeedResolvers<ContextType>;
DiscoverFeedArticle?: DiscoverFeedArticleResolvers<ContextType>;
DiscoverFeedError?: DiscoverFeedErrorResolvers<ContextType>;
DiscoverFeedResult?: DiscoverFeedResultResolvers<ContextType>;
DiscoverFeedSuccess?: DiscoverFeedSuccessResolvers<ContextType>;
DiscoverTopic?: DiscoverTopicResolvers<ContextType>;
EditDiscoverFeedError?: EditDiscoverFeedErrorResolvers<ContextType>;
EditDiscoverFeedResult?: EditDiscoverFeedResultResolvers<ContextType>;
EditDiscoverFeedSuccess?: EditDiscoverFeedSuccessResolvers<ContextType>;
EmptyTrashError?: EmptyTrashErrorResolvers<ContextType>;
EmptyTrashResult?: EmptyTrashResultResolvers<ContextType>;
EmptyTrashSuccess?: EmptyTrashSuccessResolvers<ContextType>;
@ -6710,6 +7197,12 @@ export type Resolvers<ContextType = ResolverContext> = {
GenerateApiKeyError?: GenerateApiKeyErrorResolvers<ContextType>;
GenerateApiKeyResult?: GenerateApiKeyResultResolvers<ContextType>;
GenerateApiKeySuccess?: GenerateApiKeySuccessResolvers<ContextType>;
GetDiscoverFeedArticleError?: GetDiscoverFeedArticleErrorResolvers<ContextType>;
GetDiscoverFeedArticleResults?: GetDiscoverFeedArticleResultsResolvers<ContextType>;
GetDiscoverFeedArticleSuccess?: GetDiscoverFeedArticleSuccessResolvers<ContextType>;
GetDiscoverTopicError?: GetDiscoverTopicErrorResolvers<ContextType>;
GetDiscoverTopicResults?: GetDiscoverTopicResultsResolvers<ContextType>;
GetDiscoverTopicSuccess?: GetDiscoverTopicSuccessResolvers<ContextType>;
GetFollowersError?: GetFollowersErrorResolvers<ContextType>;
GetFollowersResult?: GetFollowersResultResolvers<ContextType>;
GetFollowersSuccess?: GetFollowersSuccessResolvers<ContextType>;
@ -6816,6 +7309,9 @@ export type Resolvers<ContextType = ResolverContext> = {
SaveArticleReadingProgressError?: SaveArticleReadingProgressErrorResolvers<ContextType>;
SaveArticleReadingProgressResult?: SaveArticleReadingProgressResultResolvers<ContextType>;
SaveArticleReadingProgressSuccess?: SaveArticleReadingProgressSuccessResolvers<ContextType>;
SaveDiscoverArticleError?: SaveDiscoverArticleErrorResolvers<ContextType>;
SaveDiscoverArticleResult?: SaveDiscoverArticleResultResolvers<ContextType>;
SaveDiscoverArticleSuccess?: SaveDiscoverArticleSuccessResolvers<ContextType>;
SaveError?: SaveErrorResolvers<ContextType>;
SaveFilterError?: SaveFilterErrorResolvers<ContextType>;
SaveFilterResult?: SaveFilterResultResolvers<ContextType>;

View File

@ -1,5 +1,26 @@
directive @sanitize(allowedTags: [String], maxLength: Int, minLength: Int, pattern: String) on INPUT_FIELD_DEFINITION
type AddDiscoverFeedError {
errorCodes: [AddDiscoverFeedErrorCode!]!
}
enum AddDiscoverFeedErrorCode {
BAD_REQUEST
CONFLICT
NOT_FOUND
UNAUTHORIZED
}
input AddDiscoverFeedInput {
url: String!
}
union AddDiscoverFeedResult = AddDiscoverFeedError | AddDiscoverFeedSuccess
type AddDiscoverFeedSuccess {
feed: DiscoverFeed!
}
type AddPopularReadError {
errorCodes: [AddPopularReadErrorCode!]!
}
@ -462,6 +483,47 @@ type DeleteAccountSuccess {
userID: ID!
}
type DeleteDiscoverArticleError {
errorCodes: [DeleteDiscoverArticleErrorCode!]!
}
enum DeleteDiscoverArticleErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
input DeleteDiscoverArticleInput {
discoverArticleId: ID!
}
union DeleteDiscoverArticleResult = DeleteDiscoverArticleError | DeleteDiscoverArticleSuccess
type DeleteDiscoverArticleSuccess {
id: ID!
}
type DeleteDiscoverFeedError {
errorCodes: [DeleteDiscoverFeedErrorCode!]!
}
enum DeleteDiscoverFeedErrorCode {
BAD_REQUEST
CONFLICT
NOT_FOUND
UNAUTHORIZED
}
input DeleteDiscoverFeedInput {
feedId: ID!
}
union DeleteDiscoverFeedResult = DeleteDiscoverFeedError | DeleteDiscoverFeedSuccess
type DeleteDiscoverFeedSuccess {
id: String!
}
type DeleteFilterError {
errorCodes: [DeleteFilterErrorCode!]!
}
@ -658,6 +720,72 @@ type EmptyTrashSuccess {
success: Boolean
}
type DiscoverFeed {
description: String
id: ID!
image: String
link: String!
title: String!
type: String!
visibleName: String
}
type DiscoverFeedArticle {
author: String
description: String!
feed: String!
id: ID!
image: String
publishedDate: Date
savedId: String
savedLinkUrl: String
siteName: String
slug: String!
title: String!
url: String!
}
type DiscoverFeedError {
errorCodes: [DiscoverFeedErrorCode!]!
}
enum DiscoverFeedErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
union DiscoverFeedResult = DiscoverFeedError | DiscoverFeedSuccess
type DiscoverFeedSuccess {
feeds: [DiscoverFeed]!
}
type DiscoverTopic {
description: String!
name: String!
}
type EditDiscoverFeedError {
errorCodes: [EditDiscoverFeedErrorCode!]!
}
enum EditDiscoverFeedErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
input EditDiscoverFeedInput {
feedId: ID!
name: String!
}
union EditDiscoverFeedResult = EditDiscoverFeedError | EditDiscoverFeedSuccess
type EditDiscoverFeedSuccess {
id: ID!
}
type Feature {
createdAt: Date!
expiresAt: Date
@ -808,6 +936,37 @@ type GenerateApiKeySuccess {
apiKey: ApiKey!
}
type GetDiscoverFeedArticleError {
errorCodes: [GetDiscoverFeedArticleErrorCode!]!
}
enum GetDiscoverFeedArticleErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
union GetDiscoverFeedArticleResults = GetDiscoverFeedArticleError | GetDiscoverFeedArticleSuccess
type GetDiscoverFeedArticleSuccess {
discoverArticles: [DiscoverFeedArticle]
pageInfo: PageInfo!
}
type GetDiscoverTopicError {
errorCodes: [GetDiscoverTopicErrorCode!]!
}
enum GetDiscoverTopicErrorCode {
UNAUTHORIZED
}
union GetDiscoverTopicResults = GetDiscoverTopicError | GetDiscoverTopicSuccess
type GetDiscoverTopicSuccess {
discoverTopics: [DiscoverTopic!]
}
type GetFollowersError {
errorCodes: [GetFollowersErrorCode!]!
}
@ -1211,6 +1370,7 @@ type MoveToFolderSuccess {
}
type Mutation {
addDiscoverFeed(input: AddDiscoverFeedInput!): AddDiscoverFeedResult!
addPopularRead(name: String!): AddPopularReadResult!
bulkAction(action: BulkActionType!, arguments: JSON, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult!
createArticle(input: CreateArticleInput!): CreateArticleResult!
@ -1220,6 +1380,8 @@ type Mutation {
createLabel(input: CreateLabelInput!): CreateLabelResult!
createNewsletterEmail(input: CreateNewsletterEmailInput): CreateNewsletterEmailResult!
deleteAccount(userID: ID!): DeleteAccountResult!
deleteDiscoverArticle(input: DeleteDiscoverArticleInput!): DeleteDiscoverArticleResult!
deleteDiscoverFeed(input: DeleteDiscoverFeedInput!): DeleteDiscoverFeedResult!
deleteFilter(id: ID!): DeleteFilterResult!
deleteHighlight(highlightId: ID!): DeleteHighlightResult!
deleteIntegration(id: ID!): DeleteIntegrationResult!
@ -1227,6 +1389,7 @@ type Mutation {
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteRule(id: ID!): DeleteRuleResult!
deleteWebhook(id: ID!): DeleteWebhookResult!
editDiscoverFeed(input: EditDiscoverFeedInput!): EditDiscoverFeedResult!
emptyTrash: EmptyTrashResult!
fetchContent(id: ID!): FetchContentResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
@ -1247,6 +1410,7 @@ type Mutation {
reportItem(input: ReportItemInput!): ReportItemResult!
revokeApiKey(id: ID!): RevokeApiKeyResult!
saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult!
saveDiscoverArticle(input: SaveDiscoverArticleInput!): SaveDiscoverArticleResult!
saveFile(input: SaveFileInput!): SaveResult!
saveFilter(input: SaveFilterInput!): SaveFilterResult!
savePage(input: SavePageInput!): SaveResult!
@ -1402,8 +1566,11 @@ type Query {
article(format: String, slug: String!, username: String!): ArticleResult!
articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult!
deviceTokens: DeviceTokensResult!
discoverFeeds: DiscoverFeedResult!
discoverTopics: GetDiscoverTopicResults!
feeds(input: FeedsInput!): FeedsResult!
filters: FiltersResult!
getDiscoverFeedArticles(after: String, discoverTopicId: String!, feedId: ID, first: Int): GetDiscoverFeedArticleResults!
getUserPersonalization: GetUserPersonalizationResult!
groups: GroupsResult!
hello: String
@ -1703,6 +1870,29 @@ type SaveArticleReadingProgressSuccess {
updatedArticle: Article!
}
type SaveDiscoverArticleError {
errorCodes: [SaveDiscoverArticleErrorCode!]!
}
enum SaveDiscoverArticleErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
input SaveDiscoverArticleInput {
discoverArticleId: ID!
locale: String
timezone: String
}
union SaveDiscoverArticleResult = SaveDiscoverArticleError | SaveDiscoverArticleSuccess
type SaveDiscoverArticleSuccess {
saveId: String!
url: String!
}
type SaveError {
errorCodes: [SaveErrorCode!]!
message: String

View File

@ -34,64 +34,64 @@ export const createPubSubClient = (): PubsubClient => {
userId: string,
email: string,
name: string,
username: string
username: string,
): Promise<void> => {
return publish(
'userCreated',
Buffer.from(JSON.stringify({ userId, email, name, username }))
Buffer.from(JSON.stringify({ userId, email, name, username })),
)
},
entityCreated: <T>(
type: EntityType,
data: T,
userId: string
userId: string,
): Promise<void> => {
const cleanData = deepDelete(
data as T & Record<typeof fieldsToDelete[number], unknown>,
[...fieldsToDelete]
data as T & Record<(typeof fieldsToDelete)[number], unknown>,
[...fieldsToDelete],
)
return publish(
'entityCreated',
Buffer.from(JSON.stringify({ type, userId, ...cleanData }))
Buffer.from(JSON.stringify({ type, userId, ...cleanData })),
)
},
entityUpdated: <T>(
type: EntityType,
data: T,
userId: string
userId: string,
): Promise<void> => {
const cleanData = deepDelete(
data as T & Record<typeof fieldsToDelete[number], unknown>,
[...fieldsToDelete]
data as T & Record<(typeof fieldsToDelete)[number], unknown>,
[...fieldsToDelete],
)
return publish(
'entityUpdated',
Buffer.from(JSON.stringify({ type, userId, ...cleanData }))
Buffer.from(JSON.stringify({ type, userId, ...cleanData })),
)
},
entityDeleted: (
type: EntityType,
id: string,
userId: string
userId: string,
): Promise<void> => {
return publish(
'entityDeleted',
Buffer.from(JSON.stringify({ type, id, userId }))
Buffer.from(JSON.stringify({ type, id, userId })),
)
},
reportSubmitted: (
submitterId: string,
itemUrl: string,
reportType: ReportType[],
reportComment: string
reportComment: string,
): Promise<void> => {
return publish(
'reportSubmitted',
Buffer.from(
JSON.stringify({ submitterId, itemUrl, reportType, reportComment })
)
JSON.stringify({ submitterId, itemUrl, reportType, reportComment }),
),
)
},
}
@ -101,6 +101,7 @@ export enum EntityType {
PAGE = 'page',
HIGHLIGHT = 'highlight',
LABEL = 'label',
RSS_FEED = 'feed',
}
export interface PubsubClient {
@ -108,7 +109,7 @@ export interface PubsubClient {
userId: string,
email: string,
name: string,
username: string
username: string,
) => Promise<void>
entityCreated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
entityUpdated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
@ -117,7 +118,7 @@ export interface PubsubClient {
submitterId: string | undefined,
itemUrl: string,
reportType: ReportType[],
reportComment: string
reportComment: string,
): Promise<void>
}
@ -139,7 +140,7 @@ const expired = (body: PubSubRequestBody): boolean => {
}
export const readPushSubscription = (
req: express.Request
req: express.Request,
): { message: string | undefined; expired: boolean } => {
if (req.query.token !== process.env.PUBSUB_VERIFICATION_TOKEN) {
logger.info('query does not include valid pubsub token')

View File

@ -1,19 +1,27 @@
import * as httpContext from 'express-http-context2'
import { EntityManager, EntityTarget, QueryBuilder, Repository } from 'typeorm'
import {
EntityManager,
EntityTarget,
ObjectLiteral,
QueryBuilder,
Repository,
} from 'typeorm'
import { appDataSource } from '../data_source'
import { Claims } from '../resolvers/types'
import { SetClaimsRole } from '../utils/dictionary'
export const getColumns = <T>(repository: Repository<T>): (keyof T)[] => {
export const getColumns = <T extends ObjectLiteral>(
repository: Repository<T>,
): (keyof T)[] => {
return repository.metadata.columns.map(
(col) => col.propertyName
(col) => col.propertyName,
) as (keyof T)[]
}
export const setClaims = async (
manager: EntityManager,
uid = '00000000-0000-0000-0000-000000000000',
userRole = 'user'
userRole = 'user',
): Promise<unknown> => {
const dbRole =
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
@ -27,7 +35,7 @@ export const authTrx = async <T>(
fn: (manager: EntityManager) => Promise<T>,
em = appDataSource.manager,
uid?: string,
userRole?: string
userRole?: string,
): Promise<T> => {
// if uid and dbRole are not passed in, then get them from the claims
if (!uid && !userRole) {
@ -42,11 +50,15 @@ export const authTrx = async <T>(
})
}
export const getRepository = <T>(entity: EntityTarget<T>) => {
export const getRepository = <T extends ObjectLiteral>(
entity: EntityTarget<T>,
) => {
return appDataSource.getRepository(entity)
}
export const queryBuilderToRawSql = <T>(q: QueryBuilder<T>): string => {
export const queryBuilderToRawSql = <T extends ObjectLiteral>(
q: QueryBuilder<T>,
): string => {
const queryAndParams = q.getQueryAndParameters()
let sql = queryAndParams[0]
const params = queryAndParams[1]
@ -73,7 +85,7 @@ export const queryBuilderToRawSql = <T>(q: QueryBuilder<T>): string => {
}
})
.join(',') +
"}'"
"}'",
)
} else if (value instanceof Date) {
sql = sql.replace(`$${index + 1}`, `'${value.toISOString()}'`)
@ -87,7 +99,7 @@ export const queryBuilderToRawSql = <T>(q: QueryBuilder<T>): string => {
}
export const valuesToRawSql = (
values: Record<string, string | number | boolean>
values: Record<string, string | number | boolean>,
): string => {
let sql = ''

View File

@ -0,0 +1,199 @@
import { authorized } from '../../utils/helpers'
import {
AddDiscoverFeedError,
AddDiscoverFeedErrorCode,
AddDiscoverFeedSuccess,
DiscoverFeed,
MutationAddDiscoverFeedArgs,
} from '../../generated/graphql'
import { appDataSource } from '../../data_source'
import { QueryRunner } from 'typeorm'
import axios from 'axios'
import { RSS_PARSER_CONFIG } from '../../utils/parser'
import { XMLParser } from 'fast-xml-parser'
import { EntityType } from '../../pubsub'
import { v4 } from 'uuid'
const parser = new XMLParser({
ignoreAttributes: false,
parseTagValue: true,
ignoreDeclaration: false,
ignorePiTags: false,
})
type DiscoverFeedRows = {
rows: DiscoverFeed[]
}
const extractAtomData = (
url: string,
feed: {
title: string
subtitle?: string
icon?: string
},
): Partial<DiscoverFeed> => ({
description: feed.subtitle ?? '',
title: feed.title ?? url,
image: feed.icon,
link: url,
type: 'atom',
})
const extractRssData = (
url: string,
parsedXml: {
channel: {
title: string
description?: string
['sy:updateFrequency']: number
}
image: { url: string }
},
): Partial<DiscoverFeed> => ({
description: parsedXml.channel?.description ?? '',
title: parsedXml.channel.title ?? url,
image: parsedXml.image?.url,
link: url,
type: 'rss',
})
const handleExistingSubscription = async (
queryRunner: QueryRunner,
feed: DiscoverFeed,
userId: string,
): Promise<AddDiscoverFeedSuccess | AddDiscoverFeedError> => {
// Add to existing, otherwise conflict.
const existingSubscription = await queryRunner.query(
'SELECT * FROM omnivore.discover_feed_subscription WHERE user_id = $1 and feed_id = $2',
[userId, feed.id],
)
if (existingSubscription.rows > 1) {
await queryRunner.release()
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.Conflict],
}
}
const addSubscription = await queryRunner.query(
'INSERT INTO omnivore.discover_feed_subscription(feed_id, user_id) VALUES($1, $2)',
[feed.id, userId],
)
return {
__typename: 'AddDiscoverFeedSuccess',
feed,
}
}
const addNewSubscription = async (
queryRunner: QueryRunner,
url: string,
userId: string,
): Promise<AddDiscoverFeedSuccess | AddDiscoverFeedError> => {
// First things first, we need to validate that this is an actual RSS or ATOM feed.
const response = await axios.get(url, RSS_PARSER_CONFIG)
const content = response.data
const contentType = response.headers['content-type']
const isXML =
contentType?.includes('text/rss+xml') ||
contentType?.includes('text/atom+xml') ||
contentType?.includes('application/xml')
if (!isXML) {
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.BadRequest],
}
}
const parsedFeed = parser.parse(content)
if (!parsedFeed?.rss && !parsedFeed['rdf:RDF'] && !parsedFeed['feed']) {
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.BadRequest],
}
}
const feed =
parsedFeed?.rss || parsedFeed['rdf:RDF']
? extractRssData(url, parsedFeed.rss || parsedFeed['rdf:RDF'])
: extractAtomData(url, parsedFeed.feed)
if (!feed.title) {
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.BadRequest],
}
}
const discoverFeedId = v4()
await queryRunner.query(
'INSERT INTO omnivore.discover_feed(id, title, link, image, type, description) VALUES($1, $2, $3, $4, $5, $6)',
[
discoverFeedId,
feed.title,
feed.link,
feed.image,
feed.type,
feed.description,
],
)
await queryRunner.query(
'INSERT INTO omnivore.discover_feed_subscription(feed_id, user_id) VALUES($2, $1)',
[userId, discoverFeedId],
)
await queryRunner.release()
return {
__typename: 'AddDiscoverFeedSuccess',
feed: { ...feed, id: discoverFeedId } as DiscoverFeed,
}
}
export const addDiscoverFeedResolver = authorized<
AddDiscoverFeedSuccess,
AddDiscoverFeedError,
MutationAddDiscoverFeedArgs
>(async (_, { input: { url } }, { uid, log, pubsub }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const existingFeed = (await queryRunner.query(
'SELECT id from omnivore.discover_feed where link = $1',
[url],
)) as DiscoverFeedRows
if (existingFeed.rows.length > 0) {
return await handleExistingSubscription(
queryRunner,
existingFeed.rows[0],
uid,
)
}
const result = await addNewSubscription(queryRunner, url, uid)
if (result.__typename == 'AddDiscoverFeedSuccess') {
await pubsub.entityCreated(
EntityType.RSS_FEED,
{ feed: result.feed },
uid,
)
}
return result
} catch (error) {
log.error('Error Getting Discover Articles', error)
return {
__typename: 'AddDiscoverFeedError',
errorCodes: [AddDiscoverFeedErrorCode.Unauthorized],
}
}
})

View File

@ -0,0 +1,97 @@
import { authorized } from '../../../utils/helpers'
import {
InputMaybe,
MutationSaveDiscoverArticleArgs,
SaveDiscoverArticleError,
SaveDiscoverArticleErrorCode,
SaveDiscoverArticleSuccess,
SaveSuccess,
} from '../../../generated/graphql'
import { appDataSource } from '../../../data_source'
import { QueryRunner } from 'typeorm'
import { userRepository } from '../../../repository/user'
import { saveUrl } from '../../../services/save_url'
import { v4 } from 'uuid'
export const saveDiscoverArticleResolver = authorized<
SaveDiscoverArticleSuccess,
SaveDiscoverArticleError,
MutationSaveDiscoverArticleArgs
>(
async (
_,
{ input: { discoverArticleId, timezone, locale } },
{ uid, log },
) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const user = await userRepository.findById(uid)
if (!user) {
return {
__typename: 'SaveDiscoverArticleError',
errorCodes: [SaveDiscoverArticleErrorCode.Unauthorized],
}
}
const { rows: discoverArticles } = (await queryRunner.query(
`SELECT url FROM omnivore.discover_feed_articles WHERE id=$1`,
[discoverArticleId],
)) as {
rows: {
url: string
}[]
}
if (discoverArticles.length != 1) {
return {
__typename: 'SaveDiscoverArticleError',
errorCodes: [SaveDiscoverArticleErrorCode.NotFound],
}
}
const url = discoverArticles[0].url
const savedArticle = await saveUrl(
{
url,
source: 'add-link',
clientRequestId: v4(),
locale: locale as InputMaybe<string>,
timezone: timezone as InputMaybe<string>,
},
user,
)
if (savedArticle.__typename == 'SaveError') {
return {
__typename: 'SaveDiscoverArticleError',
errorCodes: [SaveDiscoverArticleErrorCode.BadRequest],
}
}
const saveSuccess = savedArticle as SaveSuccess
await queryRunner.query(
`insert into omnivore.discover_feed_save_link (discover_article_id, user_id, article_save_id, article_save_url) VALUES ($1, $2, $3, $4) ON CONFLICT ON CONSTRAINT user_discover_feed_link DO UPDATE SET (article_save_id, article_save_url, deleted) = ($3, $4, false);`,
[discoverArticleId, uid, saveSuccess.clientRequestId, saveSuccess.url],
)
await queryRunner.release()
return {
__typename: 'SaveDiscoverArticleSuccess',
url: saveSuccess.url,
saveId: saveSuccess.clientRequestId,
}
} catch (error) {
log.error('Error Saving Article', error)
return {
__typename: 'SaveDiscoverArticleError',
errorCodes: [SaveDiscoverArticleErrorCode.Unauthorized],
}
}
},
)

View File

@ -0,0 +1,75 @@
import { authorized } from '../../../utils/helpers'
import {
DeleteDiscoverArticleError,
DeleteDiscoverArticleErrorCode,
DeleteDiscoverArticleSuccess,
MutationDeleteDiscoverArticleArgs,
} from '../../../generated/graphql'
import { appDataSource } from '../../../data_source'
import { QueryRunner } from 'typeorm'
import { userRepository } from '../../../repository/user'
import { updateLibraryItem } from '../../../services/library_item'
import { LibraryItemState } from '../../../entity/library_item'
export const deleteDiscoverArticleResolver = authorized<
DeleteDiscoverArticleSuccess,
DeleteDiscoverArticleError,
MutationDeleteDiscoverArticleArgs
>(async (_, { input: { discoverArticleId } }, { uid, log, pubsub }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const user = await userRepository.findById(uid)
if (!user) {
return {
__typename: 'DeleteDiscoverArticleError',
errorCodes: [DeleteDiscoverArticleErrorCode.Unauthorized],
}
}
const { rows: discoverArticles } = (await queryRunner.query(
`SELECT article_save_id FROM omnivore.discover_feed_save_link WHERE discover_article_id=$1 and user_id=$2`,
[discoverArticleId, uid],
)) as {
rows: { article_save_id: string }[]
}
if (discoverArticles.length != 1) {
return {
__typename: 'DeleteDiscoverArticleError',
errorCodes: [DeleteDiscoverArticleErrorCode.NotFound],
}
}
await queryRunner.query(
`UPDATE omnivore.discover_feed_save_link set deleted = true WHERE discover_article_id=$1 and user_id=$2`,
[discoverArticleId, uid],
)
await updateLibraryItem(
discoverArticles[0].article_save_id,
{
state: LibraryItemState.Deleted,
deletedAt: new Date(),
},
uid,
pubsub,
)
await queryRunner.release()
return {
__typename: 'DeleteDiscoverArticleSuccess',
id: discoverArticleId,
}
} catch (error) {
log.error('Error Deleting Article', error)
return {
__typename: 'DeleteDiscoverArticleError',
errorCodes: [DeleteDiscoverArticleErrorCode.Unauthorized],
}
}
})

View File

@ -0,0 +1,201 @@
import { authorized } from '../../../utils/helpers'
import {
GetDiscoverFeedArticleSuccess,
GetDiscoverFeedArticleError,
QueryGetDiscoverFeedArticlesArgs,
GetDiscoverFeedArticleErrorCode,
} from '../../../generated/graphql'
import { appDataSource } from '../../../data_source'
import { QueryRunner } from 'typeorm'
const COMMUNITY_FEED_ID = '8217d320-aa5a-11ee-bbfe-a7cde356f524'
type DiscoverFeedArticleDBRows = {
rows: {
id: string
feed: string
title: string
slug: string
url: string
author: string
image: string
published_at: Date
description: string
saves: number
article_save_id: string | undefined
article_save_url: string | undefined
}[]
}
const getPopularTopics = (
queryRunner: QueryRunner,
uid: string,
after: string,
amt: number,
feedId: string | null = null,
): Promise<DiscoverFeedArticleDBRows> => {
const params = [uid, amt + 1, after]
if (feedId) {
params.push(feedId)
}
return queryRunner.query(
`
SELECT id, title, feed_id as feed, slug, description, url, author, image, published_at, COALESCE(sl.count / (EXTRACT(EPOCH FROM (NOW() - published_at)) / 3600 / 24), 0) as popularity_score, article_save_id, article_save_url
FROM omnivore.omnivore.discover_feed_articles
LEFT JOIN (SELECT discover_article_id as article_id, count(*) as count FROM omnivore.discover_feed_save_link group by discover_article_id) sl on id=sl.article_id
LEFT JOIN (SELECT discover_article_id, article_save_id, article_save_url FROM omnivore.discover_feed_save_link WHERE user_id=$1 and deleted = false) su on id=su.discover_article_id
WHERE COALESCE(sl.count / (EXTRACT(EPOCH FROM (NOW() - published_at)) / 3600 / 24), 0) > 0.0
AND (feed_id in (SELECT feed_id FROM omnivore.discover_feed_subscription WHERE user_id = $1) OR feed_id = '${COMMUNITY_FEED_ID}') ${
feedId != null ? `AND feed_id = $4` : ''
}
ORDER BY popularity_score DESC
LIMIT $2 OFFSET $3
`,
params,
) as Promise<DiscoverFeedArticleDBRows>
}
const getAllTopics = (
queryRunner: QueryRunner,
uid: string,
after: string,
amt: number,
feedId: string | null = null,
): Promise<DiscoverFeedArticleDBRows> => {
const params = [uid, amt + 1, after]
if (feedId) {
params.push(feedId)
}
return queryRunner.query(
`
SELECT id, title, feed_id as feed, slug, description, url, author, image, published_at, article_save_id, article_save_url
FROM omnivore.omnivore.discover_feed_articles
LEFT JOIN (SELECT discover_article_id, article_save_id, article_save_url FROM omnivore.discover_feed_save_link WHERE user_id=$1 and deleted = false) su on id=su.discover_article_id
WHERE (feed_id in (SELECT feed_id FROM omnivore.discover_feed_subscription WHERE user_id = $1) OR feed_id = '${COMMUNITY_FEED_ID}')
${feedId != null ? `AND feed_id = $4` : ''}
ORDER BY published_at DESC
LIMIT $2 OFFSET $3
`,
params,
) as Promise<DiscoverFeedArticleDBRows>
}
const getTopicInformation = (
queryRunner: QueryRunner,
discoverTopicId: string,
uid: string,
after: string,
amt: number,
feedId: string | null = null,
): Promise<DiscoverFeedArticleDBRows> => {
const params = [uid, discoverTopicId, amt + 1, Number(after)]
if (feedId) {
params.push(feedId)
}
return queryRunner.query(
`SELECT id, title, feed_id as feed, slug, description, url, author, image, published_at, article_save_id, article_save_url
FROM omnivore.discover_feed_articles
INNER JOIN (SELECT discover_feed_article_id FROM omnivore.discover_feed_article_topic_link WHERE discover_topic_name=$2) topic on topic.discover_feed_article_id=id
LEFT JOIN (SELECT discover_article_id, article_save_id, article_save_url FROM omnivore.discover_feed_save_link WHERE user_id=$1 and deleted = false) su on id=su.discover_article_id
WHERE (feed_id in (SELECT feed_id FROM omnivore.discover_feed_subscription WHERE user_id = $1) OR feed_id = '${COMMUNITY_FEED_ID}')
${feedId != null ? `AND feed_id = $5` : ''}
ORDER BY published_at DESC
LIMIT $3 OFFSET $4
`,
params,
) as Promise<DiscoverFeedArticleDBRows>
}
export const getDiscoverFeedArticlesResolver = authorized<
GetDiscoverFeedArticleSuccess,
GetDiscoverFeedArticleError,
QueryGetDiscoverFeedArticlesArgs
>(async (_, { discoverTopicId, feedId, first, after }, { uid, log }) => {
try {
const startCursor: string = after || ''
const firstAmnt = Math.min(first || 10, 100) // limit to 100 items
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const { rows: topics } = (await queryRunner.query(
`SELECT * FROM "omnivore"."discover_topics" WHERE "name" = $1`,
[discoverTopicId],
)) as { rows: unknown[] }
if (topics.length == 0) {
return {
__typename: 'GetDiscoverFeedArticleError',
errorCodes: [GetDiscoverFeedArticleErrorCode.Unauthorized], // TODO - no.
}
}
let discoverArticles: DiscoverFeedArticleDBRows = { rows: [] }
if (discoverTopicId === 'Popular') {
discoverArticles = await getPopularTopics(
queryRunner,
uid,
startCursor,
firstAmnt,
feedId ?? null,
)
} else if (discoverTopicId === 'All') {
discoverArticles = await getAllTopics(
queryRunner,
uid,
startCursor,
firstAmnt,
feedId ?? null,
)
} else {
discoverArticles = await getTopicInformation(
queryRunner,
discoverTopicId,
uid,
startCursor,
firstAmnt,
feedId ?? null,
)
}
await queryRunner.release()
return {
__typename: 'GetDiscoverFeedArticleSuccess',
discoverArticles: discoverArticles.rows.slice(0, firstAmnt).map((it) => ({
author: it.author,
id: it.id,
feed: it.feed,
slug: it.slug,
publishedDate: it.published_at,
description: it.description,
url: it.url,
title: it.title,
image: it.image,
saves: it.saves,
savedLinkUrl: it.article_save_url,
savedId: it.article_save_id,
__typename: 'DiscoverFeedArticle',
siteName: it.url,
})),
pageInfo: {
endCursor: `${
Number(startCursor) +
Math.min(discoverArticles.rows.length, firstAmnt)
}`,
hasNextPage: discoverArticles.rows.length > firstAmnt,
hasPreviousPage: Number(startCursor) != 0,
startCursor: Number(startCursor).toString(),
totalCount: Math.min(discoverArticles.rows.length, firstAmnt),
},
}
} catch (error) {
log.error('Error Getting Discover Feed Articles', error)
return {
__typename: 'GetDiscoverFeedArticleError',
errorCodes: [GetDiscoverFeedArticleErrorCode.Unauthorized],
}
}
})

View File

@ -0,0 +1,58 @@
import { authorized, getAbsoluteUrl } from '../../utils/helpers'
import {
DeleteDiscoverFeedError,
DeleteDiscoverFeedErrorCode,
DeleteDiscoverFeedSuccess,
MutationDeleteDiscoverFeedArgs,
} from '../../generated/graphql'
import { appDataSource } from '../../data_source'
import { QueryRunner } from 'typeorm'
export const deleteDiscoverFeedsResolver = authorized<
DeleteDiscoverFeedSuccess,
DeleteDiscoverFeedError,
MutationDeleteDiscoverFeedArgs
>(async (_, { input: { feedId } }, { uid, log }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
// Ensure that it actually exists for the user.
const feeds = (await queryRunner.query(
`SELECT * FROM omnivore.discover_feed_subscription sub
WHERE sub.user_id = $1 and sub.feed_id = $2`,
[uid, feedId],
)) as {
rows: {
feed_id: string
}[]
}
if (feeds.rows.length == 0) {
return {
__typename: 'DeleteDiscoverFeedError',
errorCodes: [DeleteDiscoverFeedErrorCode.NotFound],
}
}
await queryRunner.query(
`DELETE FROM omnivore.discover_feed_subscription sub
WHERE sub.user_id = $1 and sub.feed_id = $2`,
[uid, feedId],
)
await queryRunner.release()
return {
__typename: 'DeleteDiscoverFeedSuccess',
id: feedId,
}
} catch (error) {
log.error('Error Getting Discover Feed Subscriptions', error)
return {
__typename: 'DeleteDiscoverFeedError',
errorCodes: [DeleteDiscoverFeedErrorCode.Unauthorized],
}
}
})

View File

@ -0,0 +1,58 @@
import { authorized } from '../../utils/helpers'
import {
EditDiscoverFeedError,
EditDiscoverFeedErrorCode,
EditDiscoverFeedSuccess,
MutationEditDiscoverFeedArgs,
} from '../../generated/graphql'
import { appDataSource } from '../../data_source'
import { QueryRunner } from 'typeorm'
export const editDiscoverFeedsResolver = authorized<
EditDiscoverFeedSuccess,
EditDiscoverFeedError,
MutationEditDiscoverFeedArgs
>(async (_, { input: { feedId, name } }, { uid, log }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
// Ensure that it actually exists for the user.
const feeds = (await queryRunner.query(
`SELECT * FROM omnivore.discover_feed_subscription sub
WHERE sub.user_id = $1 and sub.feed_id = $2`,
[uid, feedId],
)) as {
rows: {
feed_id: string
}[]
}
if (feeds.rows.length == 0) {
return {
__typename: 'EditDiscoverFeedError',
errorCodes: [EditDiscoverFeedErrorCode.NotFound],
}
}
await queryRunner.query(
`UPDATE omnivore.discover_feed_subscription SET visible_name = $1
WHERE user_id = $2 and feed_id = $3`,
[name, uid, feedId],
)
await queryRunner.release()
return {
__typename: 'EditDiscoverFeedSuccess',
id: feedId,
}
} catch (error) {
log.error('Error Updating Discover Feed Subscriptions', error)
return {
__typename: 'EditDiscoverFeedError',
errorCodes: [EditDiscoverFeedErrorCode.Unauthorized],
}
}
})

View File

@ -0,0 +1,42 @@
import { authorized, getAbsoluteUrl } from '../../utils/helpers'
import {
DiscoverFeed,
DiscoverFeedError,
DiscoverFeedErrorCode,
DiscoverFeedSuccess,
} from '../../generated/graphql'
import { appDataSource } from '../../data_source'
import { QueryRunner } from 'typeorm'
export const getDiscoverFeedsResolver = authorized<
DiscoverFeedSuccess,
DiscoverFeedError
>(async (_, _args, { uid, log }) => {
try {
const queryRunner = (await appDataSource
.createQueryRunner()
.connect()) as QueryRunner
const existingFeed = (await queryRunner.query(
`SELECT *, COALESCE(visible_name, title) as "visibleName" FROM omnivore.discover_feed_subscription sub
INNER JOIN omnivore.discover_feed feed on sub.feed_id=id
WHERE sub.user_id = $1`,
[uid],
)) as {
rows: DiscoverFeed[]
}
await queryRunner.release()
return {
__typename: 'DiscoverFeedSuccess',
feeds: existingFeed.rows || [],
}
} catch (error) {
log.error('Error Getting Discover Feed Subscriptions', error)
return {
__typename: 'DiscoverFeedError',
errorCodes: [DiscoverFeedErrorCode.Unauthorized],
}
}
})

View File

@ -0,0 +1,7 @@
export * from './add'
export * from './articles/get'
export * from './get'
export * from './articles/delete'
export * from './delete'
export * from './articles/add'
export * from './edit'

View File

@ -141,6 +141,15 @@ import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails'
import { recentSearchesResolver } from './recent_searches'
import { WithDataSourcesContext } from './types'
import { updateEmailResolver } from './user'
import {
addDiscoverFeedResolver,
getDiscoverFeedsResolver,
getDiscoverFeedArticlesResolver,
saveDiscoverArticleResolver,
deleteDiscoverArticleResolver,
deleteDiscoverFeedsResolver,
editDiscoverFeedsResolver,
} from './discover_feeds'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
@ -150,7 +159,7 @@ type ResultResolveType = {
}
const resultResolveTypeResolver = (
resolverName: string
resolverName: string,
): ResultResolveType => ({
[`${resolverName}Result`]: {
__resolveType: (obj) =>
@ -230,12 +239,19 @@ export const functionResolvers = {
updateSubscription: updateSubscriptionResolver,
updateFilter: updateFilterResolver,
updateEmail: updateEmailResolver,
saveDiscoverArticle: saveDiscoverArticleResolver,
deleteDiscoverArticle: deleteDiscoverArticleResolver,
moveToFolder: moveToFolderResolver,
updateNewsletterEmail: updateNewsletterEmailResolver,
addDiscoverFeed: addDiscoverFeedResolver,
deleteDiscoverFeed: deleteDiscoverFeedsResolver,
editDiscoverFeed: editDiscoverFeedsResolver,
emptyTrash: emptyTrashResolver,
},
Query: {
me: getMeUserResolver,
getDiscoverFeedArticles: getDiscoverFeedArticlesResolver,
discoverFeeds: getDiscoverFeedsResolver,
user: getUserResolver,
users: getAllUsersResolver,
validateUsername: validateUsernameResolver,
@ -271,7 +287,7 @@ export const functionResolvers = {
async intercomHash(
user: User,
__: Record<string, unknown>,
ctx: WithDataSourcesContext
ctx: WithDataSourcesContext,
) {
if (env.intercom.secretKey) {
const userIdentifier = user.id.toString()
@ -336,7 +352,7 @@ export const functionResolvers = {
async labels(
article: { id: string; labels?: Label[]; labelNames?: string[] | null },
_: unknown,
ctx: WithDataSourcesContext
ctx: WithDataSourcesContext,
) {
if (article.labels) return article.labels
@ -361,7 +377,7 @@ export const functionResolvers = {
createdByMe(
highlight: { user: { id: string } },
__: unknown,
ctx: WithDataSourcesContext
ctx: WithDataSourcesContext,
) {
return highlight.user.id === ctx.uid
},
@ -411,7 +427,7 @@ export const functionResolvers = {
async labels(
item: { id: string; labels?: Label[]; labelNames?: string[] | null },
_: unknown,
ctx: WithDataSourcesContext
ctx: WithDataSourcesContext,
) {
if (item.labels) return item.labels
@ -428,14 +444,14 @@ export const functionResolvers = {
recommenderNames?: string[] | null
},
_: unknown,
ctx: WithDataSourcesContext
ctx: WithDataSourcesContext,
) {
if (item.recommendations) return item.recommendations
if (item.recommenderNames && item.recommenderNames.length > 0) {
const recommendations = await findRecommendationsByLibraryItemId(
item.id,
ctx.uid
ctx.uid,
)
return recommendations.map(recommandationDataToRecommendation)
}
@ -449,7 +465,7 @@ export const functionResolvers = {
highlightAnnotations?: string[] | null
},
_: unknown,
ctx: WithDataSourcesContext
ctx: WithDataSourcesContext,
) {
if (item.highlights) return item.highlights

View File

@ -42,7 +42,7 @@ const optInUltraRealisticVoice = async (uid: string): Promise<Feature> => {
const MAX_USERS = 1500
// opt in to feature for the first 1500 users
const optedInFeatures = (await appDataSource.query(
const optedInFeatures: Feature[] = await appDataSource.query(
`insert into omnivore.features (user_id, name, granted_at)
select $1, $2, $3 from omnivore.features
where name = $2 and granted_at is not null
@ -51,7 +51,7 @@ const optInUltraRealisticVoice = async (uid: string): Promise<Feature> => {
do update set granted_at = $3
returning *, granted_at as "grantedAt", created_at as "createdAt", updated_at as "updatedAt";`,
[uid, FeatureName.UltraRealisticVoice, new Date(), MAX_USERS]
)) as Feature[]
)
// if no new features were created then user has exceeded max users
if (optedInFeatures.length === 0) {

View File

@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-base-to-string */
import { preParseContent } from '@omnivore/content-handler'
import { Readability } from '@omnivore/readability'
import addressparser from 'addressparser'

View File

@ -25,7 +25,6 @@
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"chai-string": "^1.5.0",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^10.0.0",
"nock": "^13.2.9"
},

View File

@ -1,5 +1,5 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"declaration": true,

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,11 @@
-- Type: UNDO
-- Name: add_discover_feed_tables
-- Description: Add Discovery Feed Tables, including counts.
DROP TABLE omnivore.discover_feed;
DROP TABLE omnivore.discover_feed_subscription;
DROP TABLE omnivore.discover_feed_articles;
DROP TABLE omnivore.discover_feed_save_link CASCADE;
DROP TABLE omnivore.discover_feed_article_topic_link;
DROP TABLE omnivore.discover_topics CASCADE;
DROP TABLE omnivore.discover_topic_embedding_link;

View File

@ -0,0 +1,2 @@
API_ENV=local
DISCORD_BOT_KEY=BlaBlaBla

View File

@ -0,0 +1,11 @@
{
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"ignorePatterns": ["**/index.ts"],
"rules": {
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}

View File

@ -0,0 +1,33 @@
FROM node:18.16 as builder
WORKDIR /app
RUN apt-get update && apt-get install -y g++ make python3
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .prettierrc .
COPY .eslintrc .
COPY /packages/discord/src ./packages/discord/src
COPY /packages/discord/package.json ./packages/discord/package.json
COPY /packages/discord/tsconfig.json ./packages/discord/tsconfig.json
RUN yarn install --pure-lockfile
RUN yarn workspace @omnivore/discord build
FROM node:18.16 as runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/packages/discord/dist /app/packages/discord/dist
COPY --from=builder /app/packages/discord/package.json /app/packages/discord/package.json
COPY --from=builder /app/packages/discord/node_modules /app/packages/discord/node_modules
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json
CMD ["yarn", "workspace", "@omnivore/discord", "start"]

View File

@ -0,0 +1,19 @@
{
"name": "@omnivore/discord",
"version": "1.0.0",
"description": "A Discord Bot to extract features to add to Community Picks",
"scripts": {
"build": "tsc",
"dev": "ts-node-dev --files src/index.ts",
"start": "node dist/index.js",
"lint": "eslint src --ext ts,js,tsx,jsx",
"lint:fix": "eslint src --fix --ext ts,js,tsx,jsx",
"test:typecheck": "tsc --noEmit"
},
"dependencies": {
"discord.js": "^14.14.1",
"@google-cloud/pubsub": "^4.0.0"
},
"author": "",
"license": "ISC"
}

View File

@ -0,0 +1,73 @@
// @ts-nocheck
import {
Client,
Partials,
GatewayIntentBits,
Events,
MessageReaction,
User,
Embed,
} from 'discord.js'
import { PubSub } from '@google-cloud/pubsub'
import { OmnivoreArticle } from './types/OmnivoreArticle'
import { slugify } from 'voca'
const client = new Client({
partials: [Partials.Message, Partials.Reaction],
intents: [
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.Guilds,
],
})
const pubSubClient = new PubSub()
const VALID_USERS = new Set([
'danielprindii',
'riiku',
'hongbowu',
'mollydot',
'jackson.harper',
'podginator',
]) // Will have missed people here
const TOPIC_NAME = 'discordCommunityArticles'
client.once(Events.ClientReady, () => {
console.log('Ready!')
})
const createMessageFromEmbed = (embed: Embed): OmnivoreArticle => {
return {
slug: slugify(embed.url),
title: embed.title,
description: embed.description,
image: embed.thumbnail?.url,
url: embed.url,
authors: embed.author?.name ?? new URL(embed.url).host,
publishedAt: new Date(),
site: embed.url,
type: 'community',
}
}
client.on(
Events.MessageReactionAdd,
async (props: MessageReaction, user: User): Promise<void> => {
const emoji = props.emoji.name
const message = props.message.partial
? await props.message.fetch(true)
: props.message
const embed = message.embeds[0]
const userName = user.username
console.log(embed)
if (emoji === '🦥' && VALID_USERS.has(userName) && embed) {
await pubSubClient
.topic(TOPIC_NAME)
.publishMessage({ json: createMessageFromEmbed(embed) })
}
},
)
client.login(process.env.DISCORD_BOT_KEY)

View File

@ -0,0 +1,11 @@
export type OmnivoreArticle = {
slug: string
title: string
description: string
image?: string
authors: string
site: string
url: string
publishedAt: Date
type: 'community'
}

View File

@ -0,0 +1,14 @@
{
"extends": "./../../tsconfig.json",
"compileOnSave": false,
"include": ["src/**/*"],
"ts-node": {
"files": true
},
"exclude": ["**/node_modules"],
"compilerOptions": {
"baseUrl": "./",
"outDir": "dist",
}
}

View File

@ -0,0 +1,51 @@
{
"extends": "tslint:recommended",
"rulesDirectory": ["codelyzer"],
"rules": {
"array-type": false,
"arrow-parens": false,
"deprecation": {
"severity": "warn"
},
"import-blacklist": [true, "rxjs/Rx"],
"interface-name": false,
"max-classes-per-file": false,
"max-line-length": [true, 140],
"member-access": false,
"member-ordering": [
true,
{
"order": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"no-consecutive-blank-lines": false,
"no-console": [true, "debug", "info", "time", "timeEnd", "trace"],
"no-empty": false,
"no-inferrable-types": [true, "ignore-params"],
"no-non-null-assertion": true,
"no-redundant-jsdoc": true,
"no-switch-case-fall-through": true,
"no-use-before-declare": true,
"no-var-requires": false,
"object-literal-key-quotes": [true, "as-needed"],
"object-literal-sort-keys": false,
"ordered-imports": false,
"quotemark": [true, "single"],
"trailing-comma": false,
"no-output-on-prefix": true,
"no-inputs-metadata-property": true,
"no-outputs-metadata-property": true,
"no-host-metadata-property": true,
"no-input-rename": true,
"no-output-rename": true,
"use-life-cycle-interface": true,
"use-pipe-transform-interface": true,
"component-class-suffix": true,
"directive-class-suffix": true
}
}

View File

@ -0,0 +1,11 @@
API_ENV=local
PG_HOST=localhost
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=postgres
PG_POOL_MAX=20
PG_DB=omnivore
IMAGE_PROXY_URL=http://localhost:8080
IMAGE_PROXY_SECRET_KEY=some-secret
GCP_PROJECT_ID=omnivore-local
OPEN_AI_API_KEY=some-key

View File

@ -0,0 +1,13 @@
{
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/strictNullChecks": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off"
}
}

131
packages/discover/.gitignore vendored Normal file
View File

@ -0,0 +1,131 @@
.idea/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

View File

@ -0,0 +1,33 @@
FROM node:18.16 as builder
WORKDIR /app
RUN apt-get update && apt-get install -y g++ make python3
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .prettierrc .
COPY .eslintrc .
COPY /packages/discover/src ./packages/discover/src
COPY /packages/discover/package.json ./packages/discover/package.json
COPY /packages/discover/tsconfig.json ./packages/discover/tsconfig.json
RUN yarn install --pure-lockfile
RUN yarn workspace @omnivore/discover build
FROM node:18.16 as runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/packages/discover/dist /app/packages/discover/dist
COPY --from=builder /app/packages/discover/package.json /app/packages/discover/package.json
COPY --from=builder /app/packages/discover/node_modules /app/packages/discover/node_modules
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json
CMD ["yarn", "workspace", "@omnivore/discover", "start"]

View File

@ -0,0 +1,78 @@
# omnivore-discover
## What is this?
One of my bi ggest problems is actually discoverability of articles. I have my five sites, and my link aggregators like Reddit. This is a bubble, and I miss a lot this way.
So I wanted to see if I could create something that would enable discoverability from Omnivore.
I had a few goals when creating Omnivore Discover.
![Example Screen](./docs/example.png)
## Features
### Automatic Categorisation
A while ago I worked a proof of concept for automatically adding user tags to an article. I ultimately still need to work on that further, but the basics for it worked well.
I wanted to take the learnings from this and use it to add automatic categorisation of stories.
I created a few topics, and added some descriptions to them. I generate an Embedding from this using OpenAIs embedding. These can be seen below.
![Topics](./docs/topic-tab.png)
When ingesting articles (see Ingesting Articles) we use their title and small description to create an Embedding. We can then use Cosine Similarity to identify which category this story should be a part of.
This is of course not 100% accurate, but it does a good enough job at categorising articles.
### Social Features
#### Discord Integration.
I created Omnivore Discover, and added it to the Omnivore WebApp.
I wanted to also add some social features to this. We have a fantastic community within the Omnivore Discord. I have found a lot of interesting reads in the #recommendations channel.
I wanted to be able to take these recommendations, and expose them to the Omnivore Community.
We do this using a Discord Bot. In order to moderate these recommendations a moderator must add an emoji (🦥) to the story.
This then gets ingested in the same way as the other stories. Meaning that it is also categorised. It also gets added to the Community Picks tab.
![Tomnivore Slack](./docs/tomnivore.png)
![community tab](./docs/community.png)
#### Popularity
There is also a popularity feed. This provides a score based on recent saves, weighting more heavily for newer articles. This allows us to have a popular tab, which shows in order the most popular stories on Omnivore Right now according to the community
![Popular Items](./docs/popular.png)
### Ingesting Articles
I ensured that articles could come from multiple locations. This is why I chose an RXJS Poller.
This project also started from the automatic labelling project. So that too was an important part of the decision to enable ingestion from multiple plages. Including a PubSub queue.
I wanted one of the main sources of the articles to be RSS Feeds.
I did this because I thought that some of this functionality might, in the future, be extendable to other RSS Feeds.
I have chose 3 article sources for now, Wired, ArsTechnica, and The Atlantic.
## Technologies
Below is a list of the technologies that were used to design this feature. This repository represents the RXJS side.
* RxJS
* Typescript
* Axios
* PGVector
* Discord Bot
* PubSub
## Running
Creation of the PubSub Topic and Subscription is external to this app.

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,44 @@
{
"name": "@omnivore/discover",
"version": "0.0.1",
"scripts": {
"build": "tsc",
"dev": "ts-node-dev --files src/index.ts",
"start": "node dist/index.js",
"lint": "eslint src --ext ts,js,tsx,jsx",
"lint:fix": "eslint src --fix --ext ts,js,tsx,jsx",
"test:typecheck": "tsc --noEmit"
},
"dependencies": {
"aws4-axios": "^3.3.0",
"axios": "^1.5.1",
"dotenv": "^16.3.1",
"fast-xml-parser": "^4.3.2",
"html-to-text": "^9.0.5",
"lodash": "^4.17.21",
"linkedom": "^0.16.5",
"openai": "^4.11.1",
"pg": "^8.11.3",
"pg-format": "^1.0.4",
"pgvector": "^0.1.5",
"postgres": "^3.4.0",
"rxjs": "^7.8.1",
"@google-cloud/pubsub": "^4.0.0",
"uuid": "^9.0.1",
"urlsafe-base64": "^1.0.0"
},
"devDependencies": {
"@types/jsdom": "^21.1.3",
"@types/pg-format": "^1.0.3",
"@types/html-to-text": "^9.0.2",
"@types/lodash": "^4.14.201",
"@types/node": "^20.8.4",
"@types/pg": "^8.10.5",
"@types/voca": "^1.4.3",
"ts-node": "^10.9.1",
"tslib": "^2.6.2",
"@types/uuid": "^9.0.1",
"@types/urlsafe-base64": "^1.0.28"
}
}

67
packages/discover/src/env.ts Executable file
View File

@ -0,0 +1,67 @@
import * as dotenv from 'dotenv'
dotenv.config({ path: __dirname + '/./../env' })
interface BackendEnv {
pg: {
host: string
port: number
userName: string
password: string
dbName: string
pool: {
max: number
}
}
apiKey: string
openAiApiKey: string
imageProxy: {
url?: string
secretKey?: string
}
}
const envParser =
(env: { [key: string]: string | undefined }) =>
(varName: string, throwOnUndefined = true): string | undefined => {
const value = env[varName]
if (typeof value === 'string' && value) {
return value
}
if (throwOnUndefined) {
throw new Error(
`Missing ${varName} with a non-empty value in process environment`,
)
}
return
}
export function getEnv(): BackendEnv {
// Dotenv parses env file merging into proces.env which is then read into custom struct here.
dotenv.config({ path: __dirname + '/./../.env' })
const parse = envParser(process.env)
const pg = {
host: parse('PG_HOST')!,
port: parseInt(parse('PG_PORT')!, 10),
userName: parse('PG_USER')!,
password: parse('PG_PASSWORD')!,
dbName: parse('PG_DB')!,
pool: {
max: parseInt(parse('PG_POOL_MAX')!, 10),
},
}
return {
pg,
apiKey: parse('OMNIVORE_API_KEY')!,
openAiApiKey: parse('OPEN_AI_KEY')!,
imageProxy: {
url: parse('IMAGE_PROXY_URL', false),
secretKey: parse('IMAGE_PROXY_SECRET', false),
},
}
}
export const env = getEnv()

View File

@ -0,0 +1,26 @@
import { addEmbeddingToArticle$, addTopicsToArticle$ } from './lib/ai/embedding'
import {
insertArticleToStore$,
removeDuplicateArticles$,
} from './lib/store/articles'
import { merge, Observable } from 'rxjs'
import { OmnivoreArticle } from './types/OmnivoreArticle'
import { rss$ } from './lib/inputSources/articles/rss/rssIngestor'
import { putImageInProxy$ } from './lib/clients/omnivore/imageProxy'
import { communityArticles$ } from './lib/inputSources/articles/communityArticles'
const enrichedArticles$ = (): Observable<OmnivoreArticle> => {
return merge(communityArticles$, rss$) as Observable<OmnivoreArticle>
}
;(() => {
enrichedArticles$()
.pipe(
removeDuplicateArticles$,
addEmbeddingToArticle$,
addTopicsToArticle$,
putImageInProxy$,
insertArticleToStore$,
)
.subscribe((_it) => {})
})()

View File

@ -0,0 +1,122 @@
import { mergeMap } from 'rxjs/operators'
import { OmnivoreArticle } from '../../types/OmnivoreArticle'
import { OperatorFunction, pipe, share } from 'rxjs'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
import { client } from '../clients/ai/client'
import { onErrorContinue, rateLimiter } from '../utils/reactive'
import { Label } from '../../types/OmnivoreSchema'
import { sqlClient } from '../store/db'
import { toSql } from 'pgvector/pg'
export type EmbeddedOmnivoreArticle = {
embedding: Array<number>
article: OmnivoreArticle
topics: string[]
}
export type EmbeddedOmnivoreLabel = {
embedding: Array<number>
label: Label
}
// Remove, for instance, "The Verge" and " - The Verge" to avoid the cosine similarity matching on that.
const prepareTitle = (article: OmnivoreArticle): string =>
article.title
.replace(article.site, '')
.replace(/[`~!@#$%^&*()_|+\-=?;:'",.<>{}[]\\\/]/gi, '')
const getEmbeddingForArticle = async (
it: OmnivoreArticle,
): Promise<EmbeddedOmnivoreArticle> => {
console.log(`${prepareTitle(it)}: ${it.description}`)
const embedding = await client.getEmbeddings(
`${prepareTitle(it)}: ${it.summary}`,
)
return {
embedding,
article: it,
topics: [],
}
}
const addTopicsToArticle = async (
it: EmbeddedOmnivoreArticle,
): Promise<EmbeddedOmnivoreArticle> => {
const articleEmbedding = it.embedding
const topics = await sqlClient.query(
`SELECT name, similarity
FROM (SELECT discover_topic_name as name, MAX(ABS(embed.embedding <#> $1)) AS "similarity" FROM omnivore.omnivore.discover_topic_embedding_link embed group by discover_topic_name) topics
ORDER BY similarity desc`,
[toSql(articleEmbedding)],
)
// OpenAI seems to cluster things around 0.7-0.9. Through trial and error I have found 0.77 to be a fairly accurate score.
const topicNames = topics.rows
.filter(({ similarity }) => similarity > 0.77)
.map(({ name }) => name as string)
if (topicNames.length == 0) {
topicNames.push(topics.rows[0]?.name)
}
if (it.article.type == 'community') {
topicNames.push('Community Picks')
}
return {
...it,
topics: topicNames,
}
}
const getEmbeddingForLabel = async (
label: Label,
): Promise<EmbeddedOmnivoreLabel> => {
const embedding = await client.getEmbeddings(
`${label.name}${label.description ? ' : ' + label.description : ''}`,
)
console.log(
`${label.name}${label.description ? ' : ' + label.description : ''}`,
)
return {
embedding,
label,
}
}
export const rateLimitEmbedding = <T>() =>
pipe(share(), rateLimiter<T>({ resetLimit: 1000, timeMs: 60_000 }))
export const rateLimiting = rateLimitEmbedding<any>()
export const addEmbeddingToLabel: OperatorFunction<
Label,
EmbeddedOmnivoreLabel
> = pipe(
rateLimiting,
mergeMap((it: Label) => fromPromise(getEmbeddingForLabel(it))),
)
export const addEmbeddingToArticle$: OperatorFunction<
OmnivoreArticle,
EmbeddedOmnivoreArticle
> = pipe(
rateLimiting,
onErrorContinue(
mergeMap((it: OmnivoreArticle) => fromPromise(getEmbeddingForArticle(it))),
),
)
export const addTopicsToArticle$: OperatorFunction<
EmbeddedOmnivoreArticle,
EmbeddedOmnivoreArticle
> = pipe(
onErrorContinue(
mergeMap((it: EmbeddedOmnivoreArticle) =>
fromPromise(addTopicsToArticle(it)),
),
),
)

View File

@ -0,0 +1,66 @@
import { OmnivoreClient } from '../clients/omnivore/omnivore'
import { OmnivoreArticle } from '../../types/OmnivoreArticle'
import { mergeMap, OperatorFunction, pipe } from 'rxjs'
import { client } from '../clients/ai/client'
import { convert } from 'html-to-text'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
import { exponentialBackOff, rateLimiter } from '../utils/reactive'
import { env } from '../../env'
const omnivoreClient = OmnivoreClient.createOmnivoreClient(env.apiKey)
// A basic metric for now, we will see later if anything needs to be improved in this area.
// 10 Words is probably sufficient, and will reduce the need for the bill on the Summary side.
export const needsPopulating = (article: OmnivoreArticle) => {
return article.description?.split(' ').length <= 3
}
const setArticleDescription = async (
article: OmnivoreArticle,
): Promise<OmnivoreArticle> => {
const client = await omnivoreClient
const { content } = await client.fetchPage(article.slug)
return {
...article,
description: convert(content).split(' ').slice(0, 25).join(' '),
}
}
export const setArticleDescriptionAsSubsetOfContent: OperatorFunction<
OmnivoreArticle,
OmnivoreArticle
> = mergeMap(
(it: OmnivoreArticle) => fromPromise(setArticleDescription(it)),
10,
)
const enrichArticleWithAiSummary = (it: OmnivoreArticle) =>
fromPromise(
(async (article: OmnivoreArticle): Promise<OmnivoreArticle> => {
const omniClient = await omnivoreClient
const { content } = await omniClient.fetchPage(article.slug)
try {
const tokens = convert(content).slice(
0,
Math.floor(client.tokenLimit * 0.75),
)
const description = await client.summarizeText(tokens)
return { ...article, description }
} catch (e) {
console.log(`Error article: ${article.title}`)
console.log(e)
throw e
}
})(it),
)
export const enrichArticleWithAiGeneratedDescription: OperatorFunction<
OmnivoreArticle,
OmnivoreArticle
> = pipe(
rateLimiter({ resetLimit: 50, timeMs: 60_000 }),
mergeMap((it: OmnivoreArticle) =>
enrichArticleWithAiSummary(it).pipe(exponentialBackOff(30)),
),
)

View File

@ -0,0 +1,82 @@
import { EmbeddedOmnivoreLabel } from './embedding'
export type PredefinedEmbeds = Partial<
EmbeddedOmnivoreLabel & {
children?: EmbeddedOmnivoreLabel[]
parent?: EmbeddedOmnivoreLabel
}
>
// const importedEmbeddedLabels = fs
// .readFileSync(`${__dirname}/../../resources/embeddings.json`)
// .toString("utf-8");
// const embeddedLabels: PredefinedEmbeds[] = JSON.parse(importedEmbeddedLabels);
//
// export const getRelatedConcepts = async (label: Label): Promise<Label[]> => {
// const labelEmbedding = await client.getEmbeddings(label.name.toLowerCase());
//
// const predefined = (it: PredefinedEmbeds) => {
// console.log(label.name, it.label.name);
// const cosineSim = cosineSimilarity(it.embedding, labelEmbedding);
// console.log(cosineSim);
// return { sim: cosineSim, ...it };
// };
//
// const parentComparisons = embeddedLabels.reduce((acc, prev) => {
// return { ...acc, [prev.label.name]: predefined(prev) };
// }, {});
//
// const mostRelated = embeddedLabels
// .flatMap((parent) => {
// return parent.children.map((child) => ({
// ...predefined(child),
// parent: parentComparisons[parent.label.name],
// }));
// })
// .sort((a, b) => b.sim - a.sim)
// .slice(0, 2);
//
// return mostRelated.flatMap((it) => {
// return [
// {
// ...label,
// name: `article is about ${label.name.toLowerCase()} in the category ${it.parent.label.name.toLowerCase()}`,
// },
// { ...label, name: `article is about ${label.name.toLowerCase()}` },
// {
// ...label,
// name: `${it.parent.label.name.toLowerCase()}: ${label.name.toLowerCase()}`,
// },
// {
// ...label,
// name: `${it.parent.label.name.toLowerCase()}: ${label.name.toLowerCase()}, ${it.label.name.toLowerCase()}`,
// },
// {
// ...label,
// name: `article is about ${label.name.toLowerCase()} in the category ${it.parent.label.name.toLowerCase()} related to ${it.label.name.toLowerCase()}`,
// },
// {
// ...label,
// name: `article is about ${label.name.toLowerCase()} in the category ${it.label.name.toLowerCase()}`,
// },
// ];
// });
// };
//
// export const createRelatedConceptsIfNoDescription = (
// observable: Observable<Label>,
// ) => {
// return observable.pipe(
// switchMap((label: Label) => {
// if (label.description) {
// return observable;
// }
//
// return observable.pipe(
// rateLimiting,
// mergeMap((it: Label) => fromPromise(getRelatedConcepts(it))),
// mergeMap((it: Label[]) => it),
// );
// }),
// );
// };

View File

@ -0,0 +1,72 @@
import axios, { AxiosInstance } from 'axios'
import {
BedrockClientParams,
BedrockClientResponse,
BedrockInvokeParams,
} from '../../../types/Bedrock'
import { aws4Interceptor } from 'aws4-axios'
import { AiClient, Embedding } from '../../../types/AiClient'
import { SUMMARISE_PROMPT } from './prompt'
export class BedrockClient implements AiClient {
client: AxiosInstance
tokenLimit = 100_000 // (Perhaps. Not even sure of the validity of this.)
embeddingLimit = 8000
constructor(
params: BedrockClientParams = {
region: 'us-west-2',
endpoint: 'https://bedrock-runtime.us-west-2.amazonaws.com',
},
) {
this.client = axios.create({
baseURL: params.endpoint,
})
const interceptor = aws4Interceptor({
options: {
region: params.region,
service: 'bedrock',
},
})
this.client.interceptors.request.use(interceptor)
this.client.defaults.headers.common['Accept'] = '*/*'
this.client.defaults.headers.common['Content-Type'] = 'application/json'
}
_extractHttpBody(
invokeParams: BedrockInvokeParams,
): Partial<BedrockInvokeParams> {
const { model: _, prompt, ...httpCommands } = invokeParams
return { ...httpCommands, prompt: this._wrapPrompt(prompt) }
}
_wrapPrompt(prompt: string): string {
return `\nHuman: ${prompt}\nAssistant:`
}
async getEmbeddings(text: string): Promise<Embedding> {
const { data } = await this.client.post<BedrockClientResponse>(
`/model/cohere.embed-english-v3/invoke`,
{ texts: [text], input_type: 'clustering' },
)
return data.embeddings![0]
}
async summarizeText(text: string): Promise<string> {
const summariseParams = {
model: 'anthropic.claude-v2',
max_tokens_to_sample: 8192,
temperature: 1,
top_k: 250,
top_p: 0.999,
stop_sequences: ['\\n\\Human:'],
anthropic_version: 'bedrock-2023-05-31',
prompt: SUMMARISE_PROMPT(text),
}
const { data } = await this.client.post<BedrockClientResponse>(
`/model/${summariseParams.model}/invoke`,
this._extractHttpBody(summariseParams),
)
return data.completion
}
}

View File

@ -0,0 +1,4 @@
import { AiClient } from '../../../types/AiClient'
import { OpenAiClient } from './openAi'
export const client: AiClient = new OpenAiClient()

View File

@ -0,0 +1,38 @@
import { AiClient, Embedding } from '../../../types/AiClient'
import { OpenAI } from 'openai'
import { SUMMARISE_PROMPT } from './prompt'
import { env } from '../../../env'
export type OpenAiParams = {
apiKey: string // defaults to process.env["OPEN_AI_KEY"]
}
export class OpenAiClient implements AiClient {
client: OpenAI
tokenLimit = 4096
embeddingLimit = 8191
constructor(openAiParams: OpenAiParams = { apiKey: env.openAiApiKey }) {
this.client = new OpenAI(openAiParams)
}
async getEmbeddings(input: string): Promise<Embedding> {
const embedding = await this.client.embeddings.create({
input,
model: 'text-embedding-ada-002',
})
return embedding.data[0].embedding
}
async summarizeText(text: string): Promise<string> {
const prompt = `${SUMMARISE_PROMPT(text)}`
const completion = await this.client.chat.completions.create({
messages: [{ role: 'user', content: prompt }],
model: 'gpt-3.5-turbo',
stream: false,
})
return completion.choices[0]?.message?.content ?? ''
}
}

View File

@ -0,0 +1,2 @@
export const SUMMARISE_PROMPT = (articleContent: string) =>
`Please create a summary of the article below. Please Do not exceed 25 words. Please do not add any of your own prose.\n${articleContent}\n' Here is a 25 word summary of the article:\n`

View File

@ -0,0 +1,28 @@
import { pipe } from 'rxjs'
import { map } from 'rxjs/operators'
import { EmbeddedOmnivoreArticle } from '../../ai/embedding'
import { env } from '../../../env'
import { createImageProxyUrl } from '../../utils/imageproxy'
import { onErrorContinue } from '../../utils/reactive'
export const addImageToProxy = (imageUrl: string): string => {
// For testing purposes, really.
if (env.imageProxy.url) {
return createImageProxyUrl(imageUrl)
}
return imageUrl
}
export const putImageInProxy$ = pipe(
onErrorContinue(
map((it: EmbeddedOmnivoreArticle, _idx: number) => {
return {
...it,
article: {
...it.article,
image: it.article.image && addImageToProxy(it.article.image),
},
}
}),
),
)

View File

@ -0,0 +1,227 @@
import axios, { type AxiosResponse } from 'axios'
import {
type Article,
type SearchItemEdge,
type ArticleSuccess,
Label,
LabelsSuccess,
} from '../../../types/OmnivoreSchema'
const API_URL =
process.env.OMNIVORE_API_URL ?? 'https://api-prod.omnivore.app/api'
export class OmnivoreClient {
username: string
token: string
private constructor(username: string, token: string) {
this.username = username
this.token = token
}
static async createOmnivoreClient(token: string): Promise<OmnivoreClient> {
return new OmnivoreClient(await this.getUsername(token), token)
}
private static async getUsername(token: string): Promise<string> {
const data = JSON.stringify({
query: `query GetUsername {
me {
profile {
username
}
}
}
`,
})
const response = await axios
.post(`${API_URL}/graphql`, data, {
headers: {
Cookie: `auth=${token};`,
'Content-Type': 'application/json',
},
})
.catch((error) => {
console.error(error)
throw error
})
return response.data.data.me.profile.username as string
}
async fetchPages(): Promise<SearchItemEdge[]> {
const data = {
query: `query Search($after: String, $first: Int, $query: String) {
search(first: $first, after: $after, query: $query) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
pageType
contentReader
createdAt
isArchived
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
id
name
color
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
savedAt
wordsCount
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
}`,
variables: { query: 'in:inbox', after: '0', first: 1000 },
}
const response = await axios
.post(`${API_URL}/graphql`, data, {
headers: {
Cookie: `auth=${process.env.OMNIVORE_AUTH_TOKEN!};`,
'Content-Type': 'application/json',
},
})
.catch((error) => {
console.error(error)
throw error
})
return response.data.data.search.edges as SearchItemEdge[]
}
async fetchPage(slug: string): Promise<Article> {
const data = JSON.stringify({
variables: {
username: this.username,
slug,
},
query: `query GetArticle(
$username: String!
$slug: String!
) {
article(username: $username, slug: $slug) {
... on ArticleSuccess {
article {
id,
title,
url,
author,
savedAt,
description,
image
content
}
}
... on ArticleError {
errorCodes
}
}
}
`,
})
const response: AxiosResponse<{ data: { article: ArticleSuccess } }> =
await axios.post(`${API_URL}/graphql`, data, {
headers: {
Cookie: `auth=${this.token};`,
'Content-Type': 'application/json',
},
})
return response.data.data.article.article
}
async getUsersTags(): Promise<Label[]> {
const data = JSON.stringify({
query: `query GetLabels{
labels {
... on LabelsSuccess {
labels {
id,
name,
color,
description,
createdAt,
position,
internal
}
}
... on LabelsError {
errorCodes
}
}
}
`,
})
const response: AxiosResponse<{ data: { labels: LabelsSuccess } }> =
await axios.post(`${API_URL}/graphql`, data, {
headers: {
Cookie: `auth=${this.token};`,
'Content-Type': 'application/json',
},
})
return response.data.data.labels.labels
}
async archiveLink(id: string): Promise<boolean> {
const mutation = `mutation ArchivePage($id: ID!) {
setLinkArchived (input: {linkId: $id, archived: true}) {
... on ArchiveLinkSuccess {
linkId
message
}
... on ArchiveLinkError {
message
errorCodes
}
}
}`
return await axios
.post(
`${API_URL}/graphql`,
{ query: mutation, variables: { id } },
{
headers: {
Cookie: `auth=${this.token};`,
'Content-Type': 'application/json',
},
},
)
.then((_) => true)
}
}

View File

@ -0,0 +1,64 @@
import { PubSub } from '@google-cloud/pubsub'
import { catchError, EMPTY, Observable, Subscriber } from 'rxjs'
import { Message } from '@google-cloud/pubsub/build/src/subscriber'
import { OmnivoreArticle } from '../../../types/OmnivoreArticle'
const TOPIC_NAME = 'discordCommunityArticles'
const client = new PubSub()
export const COMMUNITY = 'OMNIVORE_COMMUNITY'
const extractArticleFromMessage = (message: Message): OmnivoreArticle => {
const parsedMessage: OmnivoreArticle = JSON.parse(
message.data.toString(),
) as OmnivoreArticle
return {
...parsedMessage,
feedId: COMMUNITY,
publishedAt: parsedMessage.publishedAt ?? new Date(),
type: 'community',
}
}
export const communityArticles$ = new Observable(
(subscriber: Subscriber<any>) => {
client
.topic(TOPIC_NAME)
.exists()
.then((exists) => {
if (exists[0]) {
return client.topic(TOPIC_NAME).subscription(TOPIC_NAME)
}
return client
.createTopic(TOPIC_NAME)
.then((_topic) => {
return client.topic(TOPIC_NAME).createSubscription(TOPIC_NAME)
})
.then((_sub) => {
return client.topic(TOPIC_NAME).subscription(TOPIC_NAME)
})
})
.then((subscription) => {
subscription.on('message', (msg: Message) => {
subscriber.next(extractArticleFromMessage(msg))
msg.ack()
})
})
.catch((e) => {
console.error(
'Error creating Subscription, continuing without community articles...',
e,
)
})
},
).pipe(
catchError((err) => {
console.log('Caught Error, continuing')
console.error(err)
// Return an empty Observable which gets collapsed in the output
return EMPTY
}),
)

View File

@ -0,0 +1,75 @@
import { PubSub } from '@google-cloud/pubsub'
import { catchError, EMPTY, Observable, Subscriber } from 'rxjs'
import { Message } from '@google-cloud/pubsub/build/src/subscriber'
import { OmnivoreFeed } from '../../../../types/Feeds'
const TOPIC_NAME = 'entityCreated'
const client = new PubSub()
// If a user creates a brand new Feed (IE: Never before subscribed to) we will endeavor to
// create all the items from it immediately.
export const newFeeds$ = new Observable<OmnivoreFeed>(
(subscriber: Subscriber<any>) => {
client
.topic(TOPIC_NAME)
.exists()
.then((exists) => {
if (exists[0]) {
return client
.topic(TOPIC_NAME)
.subscription(`${TOPIC_NAME}Discover`)
.exists()
.then((subExists) => {
if (subExists[0]) {
return client
.topic(TOPIC_NAME)
.subscription(`${TOPIC_NAME}Discover`)
}
return client
.topic(TOPIC_NAME)
.createSubscription(`${TOPIC_NAME}Discover`)
.then((_sub) => {
return client
.topic(TOPIC_NAME)
.subscription(`${TOPIC_NAME}Discover`)
})
})
}
return client.createTopic(TOPIC_NAME).then((_) => {
return client
.topic(TOPIC_NAME)
.createSubscription(`${TOPIC_NAME}Discover`)
.then((_sub) => {
return client
.topic(TOPIC_NAME)
.subscription(`${TOPIC_NAME}Discover`)
})
})
})
.then((subscription) => {
subscription.on('message', (msg: Message) => {
const parsedMessage = JSON.parse(msg.data.toString())
if (parsedMessage.type == 'feed') {
subscriber.next(parsedMessage.feed as OmnivoreFeed)
}
msg.ack()
})
})
.catch((e) => {
console.error(
'Error creating Subscription, continuing without new feed parsing...',
e,
)
})
},
).pipe(
catchError((err) => {
console.log('Caught Error, continuing')
console.error(err)
// Return an empty Observable which gets collapsed in the output
return EMPTY
}),
)

View File

@ -0,0 +1,68 @@
import { OmnivoreArticle } from '../../../../../types/OmnivoreArticle'
import { slugify } from 'voca'
import { Observable, tap } from 'rxjs'
import { fromArrayLike } from 'rxjs/internal/observable/innerFrom'
import { mapOrNull } from '../../../../utils/reactive'
import {
getFirstParagraphForEmbedding,
removeHTMLTag,
streamHeadAndRetrieveOpenGraph,
} from './generic'
import { JSDOM } from 'jsdom'
import { OmnivoreFeed } from '../../../../../types/Feeds'
const getImage = (article: any): string | undefined => {
const html = new JSDOM(`<html>${article.content['#text']}</html>`)
return (
(html.window.document.querySelectorAll('img')[0] &&
removeHTMLTag(html.window.document.querySelectorAll('img')[0].src)) ||
undefined
)
}
const getDescription = (article: any): string | undefined => {
return getFirstParagraphForEmbedding(article.content['#text'])
}
const getDescriptionAndImage = async (article: any) => {
let image = getImage(article)
let description: string | undefined
// If we do not have the image, we should try to grab the image and description from the
// <head> of the HTML page (using OpenGraph data). We may no longer need to grab the description from the RSS feed at this point.
if (!image) {
const ogData = await streamHeadAndRetrieveOpenGraph(article.link['@_href'])
image = ogData.image
description = ogData.description
}
if (!description) {
description = getDescription(article)
}
return { image, description }
}
export const convertAtomStream = (feed: OmnivoreFeed) => (parsedXml: any) => {
return fromArrayLike(parsedXml.feed.entry).pipe(
mapOrNull(async (article: any) => {
const { image, description } = await getDescriptionAndImage(article)
return {
authors: Array.isArray(article.author.name)
? article.author.name[0]
: article.author.name,
slug: slugify(article.link['@_href']),
url: article.link['@_href'],
title: removeHTMLTag(article.title),
description: description ?? '',
summary: description ?? '',
image: image ?? '',
site: new URL(article.link['@_href']).host,
publishedAt: new Date(article.published ?? Date.now()),
type: 'rss',
feedId: feed.title,
}
}),
)
}

View File

@ -0,0 +1,5 @@
import { parseAtomOrRss } from './generic'
export = {
generic: parseAtomOrRss,
}

View File

@ -0,0 +1,98 @@
import { OmnivoreArticle } from '../../../../../types/OmnivoreArticle'
import { XMLParser } from 'fast-xml-parser'
import { Observable } from 'rxjs'
import { parseRss } from './rss'
import { parseHTML } from 'linkedom'
import { JSDOM } from 'jsdom'
import { convertAtomStream } from './atom'
import { OmnivoreContentFeed } from '../../../../../types/Feeds'
const parser = new XMLParser({
ignoreAttributes: false,
parseTagValue: true,
ignoreDeclaration: false,
ignorePiTags: false,
})
export const removeHTMLTag = (text: string): string => {
return text.replace(/<(?:"[^"]*"['"]*|'[^']*'['"]*|[^'">])+>/g, '')
}
export const getFirstParagraphForEmbedding = (text: string): string => {
const html = parseHTML(`<html>${text}</html>`)
return (
(html.document.querySelectorAll('p')[0] &&
removeHTMLTag(html.document.querySelectorAll('p')[0].innerHTML)
.split(' ')
.slice(0, 15)
.join(' ')) ||
''
)
}
export const sanitizeHtml = (html: string) => {
return html
.replace(/<style([\S\s]*?)>([\S\s]*?)<\/style>/gim, '')
?.replace(/<script([\S\s]*?)>([\S\s]*?)<\/script>/gim, '')
}
export const streamHeadAndRetrieveOpenGraph = async (link: string) => {
const html = await fetch(link).then((response) => {
if (response.body) {
const reader = response.body.getReader()
// Read chunks of data
let html = ''
const read = (): Promise<string> => {
return reader.read().then(async ({ done, value }) => {
if (done) {
return html
}
html += new TextDecoder().decode(value)
if (html.includes('</head>')) {
await reader.cancel()
return `${html.slice(0, html.indexOf('</head>') + 7)}</html>`
}
return read()
})
}
// Start reading the stream
return read()
}
})
if (html) {
const dom = new JSDOM(sanitizeHtml(html))
const description =
dom?.window?.document
.querySelector('meta[property="og:description"]')
?.getAttribute('content') ?? undefined
const image =
dom?.window?.document
?.querySelector('meta[property="og:image"]')
?.getAttribute('content') ?? undefined
return {
image,
description,
}
}
return {
image: undefined,
description: undefined,
}
}
export const parseAtomOrRss = (contentFeed: OmnivoreContentFeed) => {
const parsedXml = parser.parse(contentFeed.content)
return parsedXml.rss || parsedXml['rdf:RDF']
? parseRss(contentFeed.feed)(
parsedXml.rss?.channel?.item ||
parsedXml['rdf:RDF'].channel?.item ||
parsedXml['rdf:RDF'].item,
)
: convertAtomStream(contentFeed.feed)(parsedXml)
}

View File

@ -0,0 +1,113 @@
import { JSDOM } from 'jsdom'
import { get } from 'lodash'
import { fromArrayLike } from 'rxjs/internal/observable/innerFrom'
import { mapOrNull } from '../../../../utils/reactive'
import { slugify } from 'voca'
import {
getFirstParagraphForEmbedding,
removeHTMLTag,
streamHeadAndRetrieveOpenGraph,
} from './generic'
import { OmnivoreFeed } from '../../../../../types/Feeds'
const getImage = (article: any): string | undefined => {
// If there's a thumbnail exposed in the RSS Feed, we should default to that as it is the most likely
if (article['media:thumbnail']) {
return (
get(article, '[media:thumbnail][@_url]') ||
get(article, '[media:thumbnail][0][@_url]')
)
}
// Otherwise, if there's Media Content, we should grab that, We will grab the first as it's the most likely
// to represent the article.
if (article['media:content']) {
return (
get(article, '[media:content][@_url]') ||
get(article, '[media:content][0][@_url]')
)
}
const extractImageFromHtml = (document: string) => {
const dom = new JSDOM(document)
return dom.window.document.getElementsByTagName('img')[0]?.src
}
// I've noticed some RSS feeds have some of the content encoded like this, and sometimes this contains an img tag
if (article['content:encoded']) {
const img = extractImageFromHtml(article['content:encoded'])
if (img) {
return img
}
}
// Similarly, some of the descriptions are HTML based.
if (article['description']) {
const img = extractImageFromHtml(article['description'])
if (img) {
return img
}
}
}
const getDescription = (article: any): string | undefined => {
// So first let's check there's some description.
if (article['description']) {
// Then we need to check if there's any <p> tags - If there are we enclose the entire thing in a DOM and do the extraction.
if (/<p\b[^>]*>(.*?)<\/p>/g.test(article['description'])) {
return getFirstParagraphForEmbedding(article['description'])
}
// If there aren't, then we should just use the description. It is likely correctly formatted.
return article['description']
}
// Otherwise we might have the content HTML encoded in this, and we should grab it from here.
if (article['content:encoded']) {
return getFirstParagraphForEmbedding(article['content:encoded'])
}
return
}
const getDescriptionAndImage = async (article: any) => {
let image = getImage(article)
let description: string | undefined
// If we do not have the image, we should try to grab the image and description from the
// <head> of the HTML page (using OpenGraph data). We may no longer need to grab the description from the RSS feed at this point.
if (!image) {
const ogData = await streamHeadAndRetrieveOpenGraph(article.link)
image = ogData.image
description = ogData.description
}
if (!description) {
description = getDescription(article)
}
return { image, description }
}
export const parseRss = (feed: OmnivoreFeed) => (parsedXml: any) => {
return fromArrayLike(parsedXml).pipe(
mapOrNull(async (article: any) => {
const { description, image } = await getDescriptionAndImage(article)
return {
authors: article['dc:creator'],
slug: slugify(article.link),
url: article.link,
title: removeHTMLTag(article.title),
description: description ?? '',
summary: description ?? '',
image: image ?? '',
site: new URL(article.link).host,
publishedAt: new Date(
article.pubDate ?? article['dc:date'] ?? Date.now(),
),
type: 'rss',
feedId: feed.id,
}
}),
)
}

View File

@ -0,0 +1,69 @@
import {
concatMap,
merge,
mergeAll,
mergeMap,
Observable,
tap,
timer,
} from 'rxjs'
import axios from 'axios'
import { fromArrayLike, fromPromise } from 'rxjs/internal/observable/innerFrom'
import { OmnivoreArticle } from '../../../../types/OmnivoreArticle'
import converters from './rssConverters/converters'
import { filter, finalize } from 'rxjs/operators'
import { getRssFeeds$ } from '../../../store/feeds'
import { OmnivoreContentFeed, OmnivoreFeed } from '../../../../types/Feeds'
import { newFeeds$ } from './newFeedIngestor'
import { exponentialBackOff, onErrorContinue } from '../../../utils/reactive'
const REFRESH_DELAY_MS = 3_600_000
const getRssFeed = async (
feed: OmnivoreFeed,
): Promise<OmnivoreContentFeed | null> => {
try {
const rss = await axios.get<string>(feed.link)
return {
feed,
content: rss.data,
}
} catch (e) {
console.error('Error retrieving RSS Feed Content', e)
throw e
}
}
const rssToArticles = (site: OmnivoreFeed) =>
fromPromise(getRssFeed(site)).pipe(
filter((it): it is OmnivoreContentFeed => !!it),
mergeMap<OmnivoreContentFeed, Observable<OmnivoreArticle>>((item) =>
converters.generic(item),
),
)
export const rss$ = (() => {
let lastUpdatedTime = new Date(0)
const filteredRss$ = getRssFeeds$.pipe(
onErrorContinue(
mergeMap((it) => rssToArticles(it).pipe(exponentialBackOff(5))),
),
filter((it: OmnivoreArticle) => it.publishedAt > lastUpdatedTime),
finalize(() => {
lastUpdatedTime = new Date()
console.log(lastUpdatedTime)
}),
)
return merge(
newFeeds$.pipe(
onErrorContinue(
mergeMap((it) => rssToArticles(it).pipe(exponentialBackOff(5))),
),
),
timer(0, REFRESH_DELAY_MS).pipe(
tap((e) => console.log('Refreshing Stream')),
concatMap(() => filteredRss$),
),
)
})()

View File

@ -0,0 +1,224 @@
import { Label } from '../../../types/OmnivoreSchema'
import { fromArrayLike } from 'rxjs/internal/observable/innerFrom'
// We use this to generate the Embeddings for our topics.
const baseTopics = [
{
name: 'Technology',
description: 'this article is about Hardware',
},
{
name: 'Technology',
description: 'this article is about Big Tech',
},
{
name: 'Technology',
description: 'this article is about Software Engineering',
},
{
name: 'Technology',
description: 'this article is about Artificial Intelligence',
},
{
name: 'Technology',
description: 'this article is about Cloud Engineering',
},
{
name: 'Technology',
description: 'this article is about Security',
},
{
name: 'Politics',
description: 'this article is about world politics',
},
{
name: 'Politics',
description: 'this article is about Geopolitics',
},
{
name: 'Politics',
description: 'this article is about Climate Change',
},
{
name: 'Politics',
description: 'this article is about the economy',
},
{
name: 'Politics',
description: 'this article is about the healthcare',
},
{
name: 'Politics',
description: 'this article is about Social Justice',
},
{
name: 'Politics',
description: 'this article is about Republicans',
},
{
name: 'Politics',
description: 'this article is about Democrats',
},
{
name: 'Politics',
description: 'this article is about Elections',
},
{
name: 'Politics',
description: 'this article is about War',
},
{
name: 'Politics',
description: 'this article is about Policy',
},
{
name: 'Politics',
description: 'this article is about Laws',
},
{
name: 'Health & Wellbeing',
description: 'this article is about mental health',
},
{
name: 'Health & Wellbeing',
description: 'this article is about healthcare',
},
{
name: 'Health & Wellbeing',
description: 'this article is about food',
},
{
name: 'Health & Wellbeing',
description: 'this article is about family',
},
{
name: 'Health & Wellbeing',
description: 'this article is about relationship advice',
},
{
name: 'Health & Wellbeing',
description: 'this article is about sexual advice',
},
{
name: 'Health & Wellbeing',
description: 'this article is about physical health and working out',
},
{
name: 'Health & Wellbeing',
description: 'this article is about self care',
},
{
name: 'Health & Wellbeing',
description: 'this article is about self help',
},
{
name: 'Health & Wellbeing',
description: 'this article is about dating',
},
{
name: 'Business & Finance',
description: 'this article is about investments',
},
{
name: 'Business & Finance',
description: 'this article is about economics',
},
{
name: 'Business & Finance',
description: 'this article is about the economy',
},
{
name: 'Business & Finance',
description: 'this article is about capitalism',
},
{
name: 'Business & Finance',
description: 'this article is about Business',
},
{
name: 'Business & Finance',
description: 'this article is about Work and the Office',
},
{
name: 'Science & Education',
description: 'this article is about space',
},
{
name: 'Science & Education',
description: 'this article is about climate change',
},
{
name: 'Science & Education',
description: 'this article is about school',
},
{
name: 'Science & Education',
description: 'this article is about physics',
},
{
name: 'Science & Education',
description: 'this article is about pyschology',
},
{
name: 'Science & Education',
description: 'this article is about biology',
},
{
name: 'Science & Education',
description: 'this article is about breakthroughs',
},
{
name: 'Culture',
description: 'this article is about Entertainment',
},
{
name: 'Culture',
description: 'this article is about Books',
},
{
name: 'Culture',
description: 'this article is about Movies',
},
{
name: 'Culture',
description: 'this article is about Sports',
},
{
name: 'Culture',
description: 'this article is about Music',
},
{
name: 'Culture',
description: 'this article is about Actors',
},
{
name: 'Culture',
description: 'this article is about TV',
},
{
name: 'Culture',
description: 'this article is about Streaming',
},
{
name: 'Gaming',
description: 'this article is about PC Gaming',
},
{
name: 'Gaming',
description: 'this article is about Video Games',
},
{
name: 'Gaming',
description: 'this article is about XBOX',
},
{
name: 'Gaming',
description: 'this article is about PlayStation',
},
{
name: 'Gaming',
description: 'this article is about Nintendo',
},
]
export const discoverTopics$ = fromArrayLike(baseTopics as Label[])

View File

@ -0,0 +1,78 @@
import { EmbeddedOmnivoreArticle } from '../ai/embedding'
import { filter, map, mergeMap, bufferTime } from 'rxjs/operators'
import { toSql } from 'pgvector/pg'
import { OmnivoreArticle } from '../../types/OmnivoreArticle'
import { from, pipe } from 'rxjs'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
import { sqlClient } from './db'
import pgformat from 'pg-format'
import { v4 } from 'uuid'
import { onErrorContinue } from '../utils/reactive'
const hasStoredInDatabase = async (articleSlug: string, feedId: string) => {
const { rows } = await sqlClient.query(
'SELECT slug FROM omnivore.discover_feed_articles WHERE slug = $1 and feed_id = $2',
[articleSlug, feedId],
)
return rows && rows.length === 0
}
export const removeDuplicateArticles$ = onErrorContinue(
mergeMap((x: OmnivoreArticle) =>
fromPromise(hasStoredInDatabase(x.slug, x.feedId)).pipe(
filter(Boolean),
map(() => x),
),
),
)
export const batchInsertArticlesSql = async (
articles: EmbeddedOmnivoreArticle[],
) => {
const params = articles.map((embedded) => [
v4(),
embedded.article.title,
embedded.article.feedId,
embedded.article.slug,
embedded.article.description,
embedded.article.url,
embedded.article.authors,
embedded.article.image,
embedded.article.publishedAt,
toSql(embedded.embedding),
])
if (articles.length > 0) {
const formattedMultiInsert = pgformat(
`INSERT INTO omnivore.discover_feed_articles(id, title, feed_id, slug, description, url, author, image, published_at, embedding) VALUES %L ON CONFLICT DO NOTHING`,
params,
)
await sqlClient.query(formattedMultiInsert)
const topicLinks = articles.flatMap((it, idx) => {
const [uuid] = params[idx]
return it.topics.map((topic) => [topic, uuid])
})
const formattedTopicInsert = pgformat(
`INSERT INTO omnivore.discover_feed_article_topic_link(discover_topic_name, discover_feed_article_id) VALUES %L ON CONFLICT DO NOTHING`,
topicLinks,
)
await sqlClient.query(formattedTopicInsert)
return articles
}
return articles
}
export const insertArticleToStore$ = pipe(
bufferTime<EmbeddedOmnivoreArticle>(5000, null, 100),
onErrorContinue(
mergeMap((x: EmbeddedOmnivoreArticle[]) =>
fromPromise(batchInsertArticlesSql(x)),
),
),
mergeMap((it: EmbeddedOmnivoreArticle[]) => from(it)),
)

View File

@ -0,0 +1,11 @@
import { Pool } from 'pg'
import { env } from '../../env'
export const sqlClient = new Pool({
port: env.pg.port,
host: env.pg.host,
user: env.pg.userName,
password: env.pg.password,
max: env.pg.pool.max,
database: env.pg.dbName,
})

View File

@ -0,0 +1,14 @@
import { mergeMap, Observable, OperatorFunction } from 'rxjs'
import { sqlClient } from './db'
import { OmnivoreFeed } from '../../types/Feeds'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
export const getRssFeeds$ = fromPromise(
(async (): Promise<OmnivoreFeed[]> => {
const { rows } = (await sqlClient.query(
`SELECT * FROM omnivore.discover_feed WHERE title != 'OMNIVORE_COMMUNITY'`,
)) as { rows: OmnivoreFeed[] }
return rows
})(),
).pipe(mergeMap((it) => it))

View File

@ -0,0 +1,44 @@
import { EmbeddedOmnivoreLabel } from '../ai/embedding'
import { filter, map, mergeMap } from 'rxjs/operators'
import { toSql } from 'pgvector/pg'
import { OperatorFunction } from 'rxjs'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
import { sqlClient } from './db'
import { Label } from '../../types/OmnivoreSchema'
const hasLabelsStoredInDatabase = async (label: string) => {
const { rows } = await sqlClient.query(
`SELECT label FROM label_embeddings where label = $1`,
[label],
)
return rows && rows.length === 0
}
export const removeDuplicateLabels = mergeMap((x: Label) =>
fromPromise(hasLabelsStoredInDatabase(x.name)).pipe(
filter(Boolean),
map(() => x),
),
)
export const insertLabels = async (
label: EmbeddedOmnivoreLabel,
): Promise<EmbeddedOmnivoreLabel> => {
await sqlClient.query(
'INSERT INTO omnivore.discover_topic_embedding_link(discover_topic_name, embedding_description, embedding) VALUES($1, $2, $3)',
[label.label.name, label.label.description, toSql(label.embedding)],
)
return label
}
// export const insertLabelsToFile = async (
// label: EmbeddedOmnivoreLabel,
// ): Promise<EmbeddedOmnivoreLabel> => {
// fs.appendFileSync('./output.json', JSON.stringify(label))
// return label
// }
export const insertLabelToStore: OperatorFunction<
EmbeddedOmnivoreLabel,
EmbeddedOmnivoreLabel
> = mergeMap((x) => fromPromise(insertLabels(x)))

View File

@ -0,0 +1,29 @@
import crypto from 'crypto'
import { encode } from 'urlsafe-base64'
import { env } from '../../env'
function signImageProxyUrl(url: string): string {
return encode(
crypto.createHmac('sha256', env.imageProxy.secretKey!).update(url).digest(),
)
}
export function createImageProxyUrl(
url: string,
width = 0,
height = 0,
): string {
if (!env.imageProxy.url || !env.imageProxy.secretKey) {
return url
}
// url is already signed
if (url.startsWith(env.imageProxy.url)) {
return url
}
const urlWithOptions = `${url}#${width}x${height}`
const signature = signImageProxyUrl(urlWithOptions)
return `${env.imageProxy.url}/${width}x${height},s${signature}/${url}`
}

View File

@ -0,0 +1,13 @@
function calcVectorSize(vec: number[]) {
return Math.sqrt(vec.reduce((accum, curr) => accum + Math.pow(curr, 2), 0))
}
export function cosineSimilarity(vec1: number[], vec2: number[]) {
const dotProduct = vec1
.map((val, i) => val * vec2[i])
.reduce((accum, curr) => accum + curr, 0)
const vec1Size = calcVectorSize(vec1)
const vec2Size = calcVectorSize(vec2)
return dotProduct / (vec1Size * vec2Size)
}

View File

@ -0,0 +1,69 @@
import {
catchError,
concatMap,
delay,
EMPTY,
mergeMap,
MonoTypeOperatorFunction,
Observable,
of,
OperatorFunction,
pipe,
timer,
} from 'rxjs'
import { filter, retry } from 'rxjs/operators'
import { OmnivoreArticle } from '../../types/OmnivoreArticle'
import { fromPromise } from 'rxjs/internal/observable/innerFrom'
export const exponentialBackOff = <T>(
count: number,
): MonoTypeOperatorFunction<T> =>
retry({
count,
delay: (error, retryIndex, interval = 200) => {
const delay = Math.pow(2, retryIndex - 1) * interval
console.log(
`Backing off: attempt ${retryIndex}, Trying again in: ${delay}ms`,
)
return timer(delay)
},
})
export const onErrorContinue = (...pipes: OperatorFunction<any, any>[]) =>
mergeMap((it: any) => {
let observer: Observable<any> = of(it)
pipes.forEach((pipe) => {
observer = observer.pipe(pipe)
})
return observer.pipe(
catchError((e) => {
console.error('Error caught in pipe, skipping', e)
return EMPTY
}),
)
})
export const rateLimiter = <T>(params: {
resetLimit: number
timeMs: number
}) => {
return concatMap((it: T) => {
return of(it).pipe(delay(params.timeMs / params.resetLimit))
})
}
export function mapOrNull(project: (article: any) => Promise<OmnivoreArticle>) {
return pipe(
concatMap((item: any, _value: number) => {
try {
return fromPromise(project(item).catch((_e) => null)).pipe(
filter((it) => !!it),
) as Observable<OmnivoreArticle>
} catch (e) {
return EMPTY
}
}),
)
}

View File

@ -0,0 +1,7 @@
export type Embedding = Array<number>
export interface AiClient {
getEmbeddings(text: string): Promise<Embedding>
summarizeText(text: string): Promise<string>
tokenLimit: number
embeddingLimit: number
}

View File

@ -0,0 +1,23 @@
import { Embedding } from './AiClient'
export type BedrockClientParams = {
region: string
endpoint: string
}
export type BedrockClientResponse = {
completion: string
embedding: Embedding
embeddings?: Embedding[]
}
export type BedrockInvokeParams = {
model: string
max_tokens_to_sample: number
temperature: number
top_k: number
top_p: number
stop_sequences: string[]
anthropic_version?: string //TODO: Add the actual params.
prompt: string
}

View File

@ -0,0 +1,13 @@
export type OmnivoreFeed = {
id: string
description?: string
image?: string
link: string
title: string
type: string
}
export type OmnivoreContentFeed = {
feed: OmnivoreFeed
content: string
}

View File

@ -0,0 +1,22 @@
export type OmnivoreArticle = {
slug: string
title: string
description: string
summary: string
image?: string
authors: string
site: string
url: string
publishedAt: Date
type: 'community' | 'rss'
feedId: string
}
export type RSSArticle = {
title: string
link: string
description: string
'media:thumbnail': { '@_url': string }
'dc:creator': string
pubDate: string
}

File diff suppressed because it is too large Load Diff

View File

View File

@ -0,0 +1,9 @@
{
"extends": "./../../tsconfig.json",
"compileOnSave": false,
"include": ["./src/**/*"],
"compilerOptions": {
"outDir": "dist",
"typeRoots": ["./../../node_modules/pgvector/types"]
}
}

View File

@ -0,0 +1,51 @@
{
"extends": "tslint:recommended",
"rulesDirectory": ["codelyzer"],
"rules": {
"array-type": false,
"arrow-parens": false,
"deprecation": {
"severity": "warn"
},
"import-blacklist": [true, "rxjs/Rx"],
"interface-name": false,
"max-classes-per-file": false,
"max-line-length": [true, 140],
"member-access": false,
"member-ordering": [
true,
{
"order": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"no-consecutive-blank-lines": false,
"no-console": [true, "debug", "info", "time", "timeEnd", "trace"],
"no-empty": false,
"no-inferrable-types": [true, "ignore-params"],
"no-non-null-assertion": true,
"no-redundant-jsdoc": true,
"no-switch-case-fall-through": true,
"no-use-before-declare": true,
"no-var-requires": false,
"object-literal-key-quotes": [true, "as-needed"],
"object-literal-sort-keys": false,
"ordered-imports": false,
"quotemark": [true, "single"],
"trailing-comma": false,
"no-output-on-prefix": true,
"no-inputs-metadata-property": true,
"no-outputs-metadata-property": true,
"no-host-metadata-property": true,
"no-input-rename": true,
"no-output-rename": true,
"use-life-cycle-interface": true,
"use-pipe-transform-interface": true,
"component-class-suffix": true,
"directive-class-suffix": true
}
}

View File

@ -2,5 +2,8 @@
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}
}

View File

@ -31,8 +31,7 @@
"@types/unzip-stream": "^0.3.1",
"@types/urlsafe-base64": "^1.0.28",
"@types/uuid": "^9.0.0",
"copyfiles": "^2.4.1",
"eslint-plugin-prettier": "^4.0.0"
"copyfiles": "^2.4.1"
},
"dependencies": {
"@fast-csv/parse": "^4.3.6",

View File

@ -27,7 +27,6 @@
"@types/rfc2047": "^2.0.1",
"@types/showdown": "^2.0.1",
"chai": "^4.3.6",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^10.0.0"
},
"dependencies": {

View File

@ -1,5 +1,5 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
"rootDir": ".",

View File

@ -2,5 +2,16 @@
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
]
}
}
}

View File

@ -22,8 +22,7 @@
"@types/luxon": "^1.25.0",
"@types/mocha": "^10.0.1",
"@types/node": "^14.11.2",
"@types/uuid": "^9.0.0",
"eslint-plugin-prettier": "^4.0.0"
"@types/uuid": "^9.0.0"
},
"dependencies": {
"@google-cloud/functions-framework": "3.1.2",

View File

@ -2,5 +2,8 @@
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}
}

View File

@ -15,6 +15,7 @@
"lint": "eslint src --ext ts,js,tsx,jsx",
"compile": "tsc",
"build": "tsc",
"@types/pdfjs-dist": "^2.10.378",
"start": "functions-framework --source=build/src/ --target=pdfHandler",
"dev": "concurrently \"tsc -w\" \"nodemon --watch ./build/ --exec npm run start\"",
"gcloud-deploy": "gcloud functions deploy pdfHandler --region=$npm_config_region --runtime nodejs14 --trigger-bucket=$npm_config_bucket --env-vars-file=../gcf-shared/env-$npm_config_env.yaml",

View File

@ -1,9 +1,14 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
"rootDir": ".",
"lib": ["dom"]
"lib": ["dom"],
"noImplicitAny": false,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
},
"include": ["src"]
}

View File

@ -18,7 +18,6 @@
"devDependencies": {
"@types/node-fetch": "^2.6.6",
"chai": "^4.3.6",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^10.0.0"
},
"dependencies": {

View File

@ -2,5 +2,8 @@
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}
}

View File

@ -17,7 +17,6 @@
},
"devDependencies": {
"chai": "^4.3.6",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^10.0.0",
"nock": "^13.3.4"
},

View File

@ -2,5 +2,8 @@
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}
}

View File

@ -17,7 +17,6 @@
},
"devDependencies": {
"chai": "^4.3.6",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^10.0.0"
},
"dependencies": {

View File

@ -2,5 +2,8 @@
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}
}

View File

@ -28,7 +28,6 @@
"@types/node": "^14.11.2",
"@types/underscore": "^1.11.4",
"chai": "^4.3.6",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^10.0.0"
},
"dependencies": {

View File

@ -1,5 +1,5 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "build",
"rootDir": ".",

View File

@ -2,5 +2,8 @@
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}
}

View File

@ -18,7 +18,6 @@
"devDependencies": {
"@types/urlsafe-base64": "^1.0.28",
"chai": "^4.3.6",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^10.0.0",
"nock": "^13.3.1"
},

View File

@ -21,6 +21,8 @@
"ignorePatterns": ["next.config.js", "jest.config.js"],
"rules": {
"functional/no-mixed-type": 0,
"react/react-in-jsx-scope": 0
"react/react-in-jsx-scope": 0,
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "warn"
}
}

View File

@ -0,0 +1,70 @@
import { HStack, SpanBox, VStack } from './LayoutPrimitives'
import { StyledText } from './StyledText'
import { NewspaperClipping } from 'phosphor-react'
import { theme } from '../tokens/stitches.config'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
export function Discover(): JSX.Element {
const [isUsed, setIsUsed] = useState(false)
const router = useRouter()
useEffect(() => {
setIsUsed(window.location.pathname.includes('/discover'))
}, [])
return (
<VStack
css={{
m: '0px',
width: '100%',
borderBottom: '1px solid $thBorderColor',
px: '15px',
background: isUsed ? '$thLibrarySelectionColor' : 'none',
'&:hover': {
background: '$thLibrarySelectionColor',
cursor: 'pointer',
},
}}
onClick={() => {
router.push('/discover')
}}
alignment="start"
distribution="start"
>
<HStack css={{ width: '100%' }} distribution="start" alignment="center">
<StyledText
css={{
fontFamily: '$inter',
fontWeight: '600',
fontSize: '16px',
lineHeight: '125%',
color: '$thLibraryMenuPrimary',
pl: '10px',
pb: '10px',
mt: '20px',
mb: '10px',
}}
>
Discover
</StyledText>
<SpanBox
css={{
display: 'flex',
height: '100%',
mt: '0px',
marginLeft: 'auto',
position: 'relative',
left: '-5px',
verticalAlign: 'middle',
}}
>
<NewspaperClipping
size={15}
color={theme.colors.thLibraryMenuPrimary.toString()}
/>
</SpanBox>
</HStack>
</VStack>
)
}

View File

@ -0,0 +1,216 @@
import { Box, HStack, VStack } from '../../elements/LayoutPrimitives'
import { LibraryFilterMenu } from '../homeFeed/LibraryFilterMenu'
import { DiscoverHeader } from './DiscoverHeader/DiscoverHeader'
import { useRouter } from 'next/router'
import React, { useCallback, useEffect, useState } from "react"
import { DiscoverItemFeed } from './DiscoverFeed/DiscoverFeed'
import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery'
import toast from 'react-hot-toast'
import { Button } from '../../elements/Button'
import { showErrorToast } from '../../../lib/toastHelpers'
import {
saveDiscoverArticleMutation,
SaveDiscoverArticleOutput
} from "../../../lib/networking/mutations/saveDiscoverArticle"
import { saveUrlMutation } from "../../../lib/networking/mutations/saveUrlMutation"
import { useFetchMore } from "../../../lib/hooks/useFetchMoreScroll"
import { AddLinkModal } from "../homeFeed/AddLinkModal"
import { useGetDiscoverFeedItems } from "../../../lib/networking/queries/useGetDiscoverFeedItems"
import { useGetDiscoverFeeds } from "../../../lib/networking/queries/useGetDiscoverFeeds"
export type LayoutType = 'LIST_LAYOUT' | 'GRID_LAYOUT'
export type TopicTabData = { title: string; subTitle: string }
export function DiscoverContainer(): JSX.Element {
const router = useRouter()
const viewer = useGetViewerQuery()
const [showFilterMenu, setShowFilterMenu] = useState(false)
const [layoutType, setLayoutType] = useState<LayoutType>('GRID_LAYOUT')
const [showAddLinkModal, setShowAddLinkModal] = useState(false);
const {feeds, revalidate, isValidating} = useGetDiscoverFeeds()
const topics = [
{
title: 'Popular',
subTitle: 'Stories that are popular on Omnivore right now...',
},
{
title: 'All',
subTitle: 'All the discover stories...',
},
{
title: 'Technology',
subTitle:
'Stories about Gadgets, AI, Software and other technology related topics',
},
{
title: 'Politics',
subTitle:
'Stories about Leadership, Elections, and issues affecting countries and the world',
},
{
title: 'Health & Wellbeing',
subTitle: 'Stories about Physical, Mental and Preventative Health',
},
{
title: 'Business & Finance',
subTitle:
'Stories about the business world, startups, and the world of financial advice. ',
},
{
title: 'Science & Education',
subTitle:
'Stories about science, breakthroughs, and the way the world works. ',
},
{
title: 'Culture',
subTitle:
'Entertainment, Movies, Television and things that make life worth living',
},
{
title: 'Gaming',
subTitle: 'PC and Console gaming, reviews, and opinions',
},
]
const [selectedFeed, setSelectedFeed] = useState("All Feeds");
const { discoverItems, setTopic, activeTopic, isLoading, hasMore, setPage, page } = useGetDiscoverFeedItems(topics[1], selectedFeed)
const handleFetchMore = useCallback(() => {
if (isLoading || !hasMore) {
return
}
setPage(page + 1)
}, [page, isLoading])
useFetchMore(handleFetchMore)
const handleSaveDiscover = async (
discoverArticleId: string,
timezone: string,
locale: string
): Promise<SaveDiscoverArticleOutput | undefined> => {
const result = await saveDiscoverArticleMutation({discoverArticleId, timezone, locale})
if (result?.saveDiscoverArticle) {
toast(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article?url=${encodeURIComponent(
result.saveDiscoverArticle.url
)}`
}}
>
Read Now
</Button>
</Box>
),
{ position: 'bottom-right' }
)
return result
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
}
const handleLinkSave = async (
link: string,
timezone: string,
locale: string
): Promise<void> => {
const result = await saveUrlMutation(link, timezone, locale)
if (result) {
toast(
() => (
<Box>
Link Saved
<span style={{ padding: '16px' }} />
<Button
style="ctaDarkYellow"
autoFocus
onClick={() => {
window.location.href = `/article?url=${encodeURIComponent(
link
)}`
}}
>
Read Now
</Button>
</Box>
),
{ position: 'bottom-right' }
)
} else {
showErrorToast('Error saving link', { position: 'bottom-right' })
}
}
useEffect(() => {
if (window) {
setLayoutType(
JSON.parse(
window.localStorage.getItem('libraryLayout') || 'GRID_LAYOUT'
)
)
}
}, [])
const setTopicAndReturnToTop = (topic: TopicTabData) => {
window.scroll(0,0);
setTopic(topic);
}
return (
<VStack
css={{
height: '100%',
width: 'unset',
}}
>
<DiscoverHeader
handleLinkSubmission={handleLinkSave}
allowSelectMultiple={true}
alwaysShowHeader={false}
showFilterMenu={showFilterMenu}
setShowFilterMenu={setShowFilterMenu}
selectedFeedFilter={selectedFeed}
applyFeedFilter={setSelectedFeed}
feeds={feeds}
activeTab={activeTopic}
setActiveTab={setTopicAndReturnToTop}
layout={layoutType}
setShowAddLinkModal={setShowAddLinkModal}
setLayoutType={setLayoutType}
topics={topics}
/>
<HStack css={{ width: '100%', height: '100%' }}>
<LibraryFilterMenu
setShowAddLinkModal={setShowAddLinkModal}
searchTerm={'NONE'} // This is done to stop the library filter menu actually having a highlight. Hacky.
applySearchQuery={(searchQuery: string) => {
router?.push(`/home?q=${searchQuery}`)
}}
showFilterMenu={showFilterMenu}
setShowFilterMenu={setShowFilterMenu}
/>
<DiscoverItemFeed
layout={layoutType}
activeTab={activeTopic}
handleLinkSubmission={handleSaveDiscover}
items={discoverItems ?? []}
viewer={viewer.viewerData?.me}
/>
{ showAddLinkModal &&
<AddLinkModal
handleLinkSubmission={handleLinkSave}
onOpenChange={() => setShowAddLinkModal(false)}
/>
}
</HStack>
</VStack>
)
}

View File

@ -0,0 +1,71 @@
import { HStack, VStack } from "../../../elements/LayoutPrimitives"
import { Toaster } from 'react-hot-toast'
import { LayoutType } from '../../homeFeed/HomeFeedContainer'
import { UserBasicData } from '../../../../lib/networking/queries/useGetViewerQuery'
import { DiscoverItems } from '../DiscoverItems/DiscoverItems'
import { SaveDiscoverArticleOutput } from "../../../../lib/networking/mutations/saveDiscoverArticle"
import { HeaderText } from "../DiscoverHeader/HeaderText"
import React from "react"
import { TopicTabData } from "../DiscoverContainer"
import { DiscoverFeedItem } from "../../../../lib/networking/queries/useGetDiscoverFeedItems"
type DiscoverItemFeedProps = {
items: DiscoverFeedItem[]
layout: LayoutType
viewer?: UserBasicData
activeTab: TopicTabData
handleLinkSubmission: (
link: string,
timezone: string,
locale: string
) => Promise<SaveDiscoverArticleOutput | undefined>
}
export const DiscoverItemFeed = (props: DiscoverItemFeedProps) => {
return (
<>
<VStack
alignment="start"
distribution="start"
css={{
height: '100%',
minHeight: '100vh',
}}
>
<Toaster />
<HStack
alignment="center"
distribution={'start'}
css={{
gap: '10px',
width: '95%',
display: 'block',
'@mdDown': {
width: '95%',
display: 'none',
},
'@media (max-width: 930px)': {
display: 'none',
},
'@media (min-width: 930px)': {
width: '660px',
},
'@media (min-width: 1280px)': {
width: '1000px',
},
'@media (min-width: 1600px)': {
width: '1340px',
},
}}
>
<HeaderText
title={props.activeTab.title}
subTitle={props.activeTab.subTitle}
/>
</HStack>
<DiscoverItems {...props} />
</VStack>
</>
)
}

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