Fix Linting...
This commit is contained in:
24
.eslintrc
24
.eslintrc
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
||||
15
package.json
15
package.json
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
|
||||
199
packages/api/src/resolvers/discover_feeds/add.ts
Normal file
199
packages/api/src/resolvers/discover_feeds/add.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
97
packages/api/src/resolvers/discover_feeds/articles/add.ts
Normal file
97
packages/api/src/resolvers/discover_feeds/articles/add.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
75
packages/api/src/resolvers/discover_feeds/articles/delete.ts
Normal file
75
packages/api/src/resolvers/discover_feeds/articles/delete.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
201
packages/api/src/resolvers/discover_feeds/articles/get.ts
Normal file
201
packages/api/src/resolvers/discover_feeds/articles/get.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
58
packages/api/src/resolvers/discover_feeds/delete.ts
Normal file
58
packages/api/src/resolvers/discover_feeds/delete.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
58
packages/api/src/resolvers/discover_feeds/edit.ts
Normal file
58
packages/api/src/resolvers/discover_feeds/edit.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
42
packages/api/src/resolvers/discover_feeds/get.ts
Normal file
42
packages/api/src/resolvers/discover_feeds/get.ts
Normal 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],
|
||||
}
|
||||
}
|
||||
})
|
||||
7
packages/api/src/resolvers/discover_feeds/index.ts
Normal file
7
packages/api/src/resolvers/discover_feeds/index.ts
Normal 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'
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@tsconfig/node14/tsconfig.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"declaration": true,
|
||||
|
||||
206
packages/db/migrations/0153.do.add_discover_feeds.sql
Executable file
206
packages/db/migrations/0153.do.add_discover_feeds.sql
Executable file
File diff suppressed because one or more lines are too long
11
packages/db/migrations/0153.undo.add_discover_feeds.sql
Executable file
11
packages/db/migrations/0153.undo.add_discover_feeds.sql
Executable 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;
|
||||
2
packages/discord/.env.test
Normal file
2
packages/discord/.env.test
Normal file
@ -0,0 +1,2 @@
|
||||
API_ENV=local
|
||||
DISCORD_BOT_KEY=BlaBlaBla
|
||||
11
packages/discord/.eslintrc
Normal file
11
packages/discord/.eslintrc
Normal 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"
|
||||
}
|
||||
}
|
||||
33
packages/discord/Dockerfile
Normal file
33
packages/discord/Dockerfile
Normal 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"]
|
||||
19
packages/discord/package.json
Normal file
19
packages/discord/package.json
Normal 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"
|
||||
}
|
||||
73
packages/discord/src/index.ts
Normal file
73
packages/discord/src/index.ts
Normal 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)
|
||||
11
packages/discord/src/types/OmnivoreArticle.ts
Normal file
11
packages/discord/src/types/OmnivoreArticle.ts
Normal 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'
|
||||
}
|
||||
14
packages/discord/tsconfig.json
Normal file
14
packages/discord/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./../../tsconfig.json",
|
||||
"compileOnSave": false,
|
||||
"include": ["src/**/*"],
|
||||
"ts-node": {
|
||||
"files": true
|
||||
},
|
||||
"exclude": ["**/node_modules"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "dist",
|
||||
}
|
||||
}
|
||||
|
||||
51
packages/discord/tslint.json
Normal file
51
packages/discord/tslint.json
Normal 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
|
||||
}
|
||||
}
|
||||
11
packages/discover/.env.test
Normal file
11
packages/discover/.env.test
Normal 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
|
||||
13
packages/discover/.eslintrc
Normal file
13
packages/discover/.eslintrc
Normal 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
131
packages/discover/.gitignore
vendored
Normal 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.*
|
||||
33
packages/discover/Dockerfile
Normal file
33
packages/discover/Dockerfile
Normal 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"]
|
||||
78
packages/discover/README.md
Normal file
78
packages/discover/README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### 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
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
BIN
packages/discover/docs/community.png
Normal file
BIN
packages/discover/docs/community.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 623 KiB |
BIN
packages/discover/docs/example.png
Normal file
BIN
packages/discover/docs/example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
BIN
packages/discover/docs/popular.png
Normal file
BIN
packages/discover/docs/popular.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 400 KiB |
BIN
packages/discover/docs/tomnivore.png
Normal file
BIN
packages/discover/docs/tomnivore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
packages/discover/docs/topic-tab.png
Normal file
BIN
packages/discover/docs/topic-tab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
44
packages/discover/package.json
Normal file
44
packages/discover/package.json
Normal 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
67
packages/discover/src/env.ts
Executable 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()
|
||||
26
packages/discover/src/index.ts
Normal file
26
packages/discover/src/index.ts
Normal 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) => {})
|
||||
})()
|
||||
122
packages/discover/src/lib/ai/embedding.ts
Normal file
122
packages/discover/src/lib/ai/embedding.ts
Normal 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)),
|
||||
),
|
||||
),
|
||||
)
|
||||
66
packages/discover/src/lib/ai/enrich.ts
Normal file
66
packages/discover/src/lib/ai/enrich.ts
Normal 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)),
|
||||
),
|
||||
)
|
||||
82
packages/discover/src/lib/ai/label.ts
Normal file
82
packages/discover/src/lib/ai/label.ts
Normal 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),
|
||||
// );
|
||||
// }),
|
||||
// );
|
||||
// };
|
||||
72
packages/discover/src/lib/clients/ai/bedrock.ts
Normal file
72
packages/discover/src/lib/clients/ai/bedrock.ts
Normal 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
|
||||
}
|
||||
}
|
||||
4
packages/discover/src/lib/clients/ai/client.ts
Normal file
4
packages/discover/src/lib/clients/ai/client.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { AiClient } from '../../../types/AiClient'
|
||||
import { OpenAiClient } from './openAi'
|
||||
|
||||
export const client: AiClient = new OpenAiClient()
|
||||
38
packages/discover/src/lib/clients/ai/openAi.ts
Normal file
38
packages/discover/src/lib/clients/ai/openAi.ts
Normal 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 ?? ''
|
||||
}
|
||||
}
|
||||
2
packages/discover/src/lib/clients/ai/prompt.ts
Normal file
2
packages/discover/src/lib/clients/ai/prompt.ts
Normal 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`
|
||||
28
packages/discover/src/lib/clients/omnivore/imageProxy.ts
Normal file
28
packages/discover/src/lib/clients/omnivore/imageProxy.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
227
packages/discover/src/lib/clients/omnivore/omnivore.ts
Normal file
227
packages/discover/src/lib/clients/omnivore/omnivore.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}),
|
||||
)
|
||||
@ -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
|
||||
}),
|
||||
)
|
||||
@ -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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { parseAtomOrRss } from './generic'
|
||||
|
||||
export = {
|
||||
generic: parseAtomOrRss,
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@ -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$),
|
||||
),
|
||||
)
|
||||
})()
|
||||
224
packages/discover/src/lib/inputSources/labels/discoverTopics.ts
Normal file
224
packages/discover/src/lib/inputSources/labels/discoverTopics.ts
Normal 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[])
|
||||
78
packages/discover/src/lib/store/articles.ts
Normal file
78
packages/discover/src/lib/store/articles.ts
Normal 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)),
|
||||
)
|
||||
11
packages/discover/src/lib/store/db.ts
Normal file
11
packages/discover/src/lib/store/db.ts
Normal 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,
|
||||
})
|
||||
14
packages/discover/src/lib/store/feeds.ts
Normal file
14
packages/discover/src/lib/store/feeds.ts
Normal 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))
|
||||
44
packages/discover/src/lib/store/labels.ts
Normal file
44
packages/discover/src/lib/store/labels.ts
Normal 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)))
|
||||
29
packages/discover/src/lib/utils/imageproxy.ts
Normal file
29
packages/discover/src/lib/utils/imageproxy.ts
Normal 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}`
|
||||
}
|
||||
13
packages/discover/src/lib/utils/math.ts
Normal file
13
packages/discover/src/lib/utils/math.ts
Normal 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)
|
||||
}
|
||||
69
packages/discover/src/lib/utils/reactive.ts
Normal file
69
packages/discover/src/lib/utils/reactive.ts
Normal 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
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
7
packages/discover/src/types/AiClient.ts
Normal file
7
packages/discover/src/types/AiClient.ts
Normal 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
|
||||
}
|
||||
23
packages/discover/src/types/Bedrock.ts
Normal file
23
packages/discover/src/types/Bedrock.ts
Normal 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
|
||||
}
|
||||
13
packages/discover/src/types/Feeds.ts
Normal file
13
packages/discover/src/types/Feeds.ts
Normal 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
|
||||
}
|
||||
22
packages/discover/src/types/OmnivoreArticle.ts
Normal file
22
packages/discover/src/types/OmnivoreArticle.ts
Normal 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
|
||||
}
|
||||
3202
packages/discover/src/types/OmnivoreSchema.ts
Normal file
3202
packages/discover/src/types/OmnivoreSchema.ts
Normal file
File diff suppressed because it is too large
Load Diff
0
packages/discover/src/types/globals.d.ts
vendored
Normal file
0
packages/discover/src/types/globals.d.ts
vendored
Normal file
9
packages/discover/tsconfig.json
Normal file
9
packages/discover/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./../../tsconfig.json",
|
||||
"compileOnSave": false,
|
||||
"include": ["./src/**/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"typeRoots": ["./../../node_modules/pgvector/types"]
|
||||
}
|
||||
}
|
||||
51
packages/discover/tslint.json
Normal file
51
packages/discover/tslint.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -2,5 +2,8 @@
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@tsconfig/node14/tsconfig.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "build",
|
||||
"rootDir": ".",
|
||||
|
||||
@ -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": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -2,5 +2,8 @@
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -2,5 +2,8 @@
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"mocha": "^10.0.0",
|
||||
"nock": "^13.3.4"
|
||||
},
|
||||
|
||||
@ -2,5 +2,8 @@
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"mocha": "^10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -2,5 +2,8 @@
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@tsconfig/node14/tsconfig.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "build",
|
||||
"rootDir": ".",
|
||||
|
||||
@ -2,5 +2,8 @@
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
70
packages/web/components/elements/DiscoverMenu.tsx
Normal file
70
packages/web/components/elements/DiscoverMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
Reference in New Issue
Block a user