mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3982 from omnivore-app/feature/just-read
feat: just read api
This commit is contained in:
commit
b3578d539b
26 changed files with 1618 additions and 95 deletions
|
|
@ -15,6 +15,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@bmatei/apollo-prometheus-exporter": "^3.0.0",
|
||||
"@cospired/i18n-iso-languages": "^4.2.0",
|
||||
"@google-cloud/logging-winston": "^6.0.0",
|
||||
"@google-cloud/monitoring": "^4.0.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -32,12 +32,15 @@ import { ClaimsToSet, RequestContext, ResolverContext } from './resolvers/types'
|
|||
import ScalarResolvers from './scalars'
|
||||
import typeDefs from './schema'
|
||||
import { batchGetHighlightsFromLibraryItemIds } from './services/highlights'
|
||||
import { batchGetPublicItems } from './services/home'
|
||||
import { batchGetLabelsFromLibraryItemIds } from './services/labels'
|
||||
import { batchGetLibraryItems } from './services/library_item'
|
||||
import { batchGetRecommendationsFromLibraryItemIds } from './services/recommendation'
|
||||
import {
|
||||
countDailyServiceUsage,
|
||||
createServiceUsage,
|
||||
} from './services/service_usage'
|
||||
import { findSubscriptionsByNames } from './services/subscriptions'
|
||||
import { batchGetUploadFilesByIds } from './services/upload_file'
|
||||
import { tracer } from './tracing'
|
||||
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
|
||||
|
|
@ -112,6 +115,15 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
|
|||
batchGetRecommendationsFromLibraryItemIds
|
||||
),
|
||||
uploadFiles: new DataLoader(batchGetUploadFilesByIds),
|
||||
libraryItems: new DataLoader(batchGetLibraryItems),
|
||||
publicItems: new DataLoader(batchGetPublicItems),
|
||||
subscriptions: new DataLoader(async (names: readonly string[]) => {
|
||||
if (!claims?.uid) {
|
||||
throw new Error('No user id found in claims')
|
||||
}
|
||||
|
||||
return findSubscriptionsByNames(claims?.uid || '', names as string[])
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -204,4 +204,16 @@ export class LibraryItem {
|
|||
|
||||
@Column('text')
|
||||
highlightAnnotations?: string[]
|
||||
|
||||
@Column('timestamptz')
|
||||
seenAt?: Date
|
||||
|
||||
@Column('ltree')
|
||||
topic?: string
|
||||
|
||||
@Column('timestamptz')
|
||||
digestedAt?: Date
|
||||
|
||||
@Column('float')
|
||||
score?: number
|
||||
}
|
||||
|
|
|
|||
73
packages/api/src/entity/public_item.ts
Normal file
73
packages/api/src/entity/public_item.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
import { PublicItemSource } from './public_item_source'
|
||||
import { PublicItemStats } from './public_item_stats'
|
||||
|
||||
@Entity({ name: 'public_item' })
|
||||
export class PublicItem {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@OneToOne(() => PublicItemStats)
|
||||
stats!: PublicItemStats
|
||||
|
||||
@ManyToOne(() => PublicItemSource)
|
||||
@JoinColumn({ name: 'source_id' })
|
||||
source!: PublicItemSource
|
||||
|
||||
@Column('text')
|
||||
siteIcon?: string
|
||||
|
||||
@Column('text')
|
||||
type!: string
|
||||
|
||||
@Column('text')
|
||||
title!: string
|
||||
|
||||
@Column('text')
|
||||
url!: string
|
||||
|
||||
@Column('boolean')
|
||||
approved!: boolean
|
||||
|
||||
@Column('text')
|
||||
thumbnail?: string
|
||||
|
||||
@Column('text')
|
||||
previewContent?: string
|
||||
|
||||
@Column('text')
|
||||
languageCode?: string
|
||||
|
||||
@Column('text')
|
||||
author?: string
|
||||
|
||||
@Column('text')
|
||||
dir?: string
|
||||
|
||||
@Column('timestamptz')
|
||||
publishedAt?: Date
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('text')
|
||||
topic?: string
|
||||
|
||||
@Column('integer')
|
||||
wordCount?: number
|
||||
|
||||
@Column('text')
|
||||
siteName?: string
|
||||
}
|
||||
47
packages/api/src/entity/public_item_interaction.ts
Normal file
47
packages/api/src/entity/public_item_interaction.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
import { PublicItem } from './public_item'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity({ name: 'public_item_interactions' })
|
||||
export class PublicItemInteraction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => PublicItem, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'public_item_id' })
|
||||
publicItem!: PublicItem
|
||||
|
||||
@Column('uuid')
|
||||
publicItemId!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('timestamptz')
|
||||
seenAt!: Date
|
||||
|
||||
@Column('timestamptz')
|
||||
savedAt?: Date
|
||||
|
||||
@Column('timestamptz')
|
||||
likedAt?: Date
|
||||
|
||||
@Column('timestamptz')
|
||||
broadcastedAt?: Date
|
||||
|
||||
@Column('timestamptz')
|
||||
createdAt!: Date
|
||||
|
||||
@Column('timestamptz')
|
||||
updated!: Date
|
||||
|
||||
@Column('timestamptz')
|
||||
digested?: Date
|
||||
}
|
||||
40
packages/api/src/entity/public_item_source.ts
Normal file
40
packages/api/src/entity/public_item_source.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
@Entity({ name: 'public_item_source' })
|
||||
export class PublicItemSource {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
type!: string
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@Column('text')
|
||||
url?: string
|
||||
|
||||
@Column('boolean')
|
||||
approved!: boolean
|
||||
|
||||
@Column('text')
|
||||
icon?: string
|
||||
|
||||
@Column('text')
|
||||
topics?: string[]
|
||||
|
||||
@Column('text')
|
||||
languageCodes?: string[]
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
}
|
||||
31
packages/api/src/entity/public_item_stats.ts
Normal file
31
packages/api/src/entity/public_item_stats.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm'
|
||||
|
||||
@Entity({ name: 'public_item_stats' })
|
||||
export class PublicItemStats {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@Column('uuid')
|
||||
publicItemId!: string
|
||||
|
||||
@Column('integer')
|
||||
saveCount!: number
|
||||
|
||||
@Column('integer')
|
||||
likeCount!: number
|
||||
|
||||
@Column('integer')
|
||||
broadcastCount!: number
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date
|
||||
}
|
||||
|
|
@ -1270,6 +1270,78 @@ export enum HighlightType {
|
|||
Redaction = 'REDACTION'
|
||||
}
|
||||
|
||||
export type HomeEdge = {
|
||||
__typename?: 'HomeEdge';
|
||||
cursor: Scalars['String'];
|
||||
node: HomeSection;
|
||||
};
|
||||
|
||||
export type HomeError = {
|
||||
__typename?: 'HomeError';
|
||||
errorCodes: Array<HomeErrorCode>;
|
||||
};
|
||||
|
||||
export enum HomeErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
Pending = 'PENDING',
|
||||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type HomeItem = {
|
||||
__typename?: 'HomeItem';
|
||||
author?: Maybe<Scalars['String']>;
|
||||
broadcastCount?: Maybe<Scalars['Int']>;
|
||||
canArchive?: Maybe<Scalars['Boolean']>;
|
||||
canComment?: Maybe<Scalars['Boolean']>;
|
||||
canDelete?: Maybe<Scalars['Boolean']>;
|
||||
canSave?: Maybe<Scalars['Boolean']>;
|
||||
canShare?: Maybe<Scalars['Boolean']>;
|
||||
date: Scalars['Date'];
|
||||
dir?: Maybe<Scalars['String']>;
|
||||
id: Scalars['ID'];
|
||||
likeCount?: Maybe<Scalars['Int']>;
|
||||
previewContent?: Maybe<Scalars['String']>;
|
||||
saveCount?: Maybe<Scalars['Int']>;
|
||||
seen_at?: Maybe<Scalars['Date']>;
|
||||
source?: Maybe<HomeItemSource>;
|
||||
thumbnail?: Maybe<Scalars['String']>;
|
||||
title: Scalars['String'];
|
||||
url: Scalars['String'];
|
||||
wordCount?: Maybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
export type HomeItemSource = {
|
||||
__typename?: 'HomeItemSource';
|
||||
icon?: Maybe<Scalars['String']>;
|
||||
id?: Maybe<Scalars['ID']>;
|
||||
name: Scalars['String'];
|
||||
type: HomeItemSourceType;
|
||||
url?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export enum HomeItemSourceType {
|
||||
Library = 'LIBRARY',
|
||||
Newsletter = 'NEWSLETTER',
|
||||
Recommendation = 'RECOMMENDATION',
|
||||
Rss = 'RSS'
|
||||
}
|
||||
|
||||
export type HomeResult = HomeError | HomeSuccess;
|
||||
|
||||
export type HomeSection = {
|
||||
__typename?: 'HomeSection';
|
||||
items: Array<HomeItem>;
|
||||
layout?: Maybe<Scalars['String']>;
|
||||
thumbnail?: Maybe<Scalars['String']>;
|
||||
title?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type HomeSuccess = {
|
||||
__typename?: 'HomeSuccess';
|
||||
edges: Array<HomeEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type ImportFromIntegrationError = {
|
||||
__typename?: 'ImportFromIntegrationError';
|
||||
errorCodes: Array<ImportFromIntegrationErrorCode>;
|
||||
|
|
@ -2159,6 +2231,7 @@ export type Query = {
|
|||
getUserPersonalization: GetUserPersonalizationResult;
|
||||
groups: GroupsResult;
|
||||
hello?: Maybe<Scalars['String']>;
|
||||
home: HomeResult;
|
||||
integration: IntegrationResult;
|
||||
integrations: IntegrationsResult;
|
||||
labels: LabelsResult;
|
||||
|
|
@ -2207,6 +2280,12 @@ export type QueryGetDiscoverFeedArticlesArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type QueryHomeArgs = {
|
||||
after?: InputMaybe<Scalars['String']>;
|
||||
first?: InputMaybe<Scalars['Int']>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryIntegrationArgs = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
|
@ -3220,6 +3299,11 @@ export type Subscription = {
|
|||
url?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type SubscriptionRootType = {
|
||||
__typename?: 'SubscriptionRootType';
|
||||
hello?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export enum SubscriptionStatus {
|
||||
Active = 'ACTIVE',
|
||||
Deleted = 'DELETED',
|
||||
|
|
@ -4179,6 +4263,15 @@ export type ResolversTypes = {
|
|||
HighlightReply: ResolverTypeWrapper<HighlightReply>;
|
||||
HighlightStats: ResolverTypeWrapper<HighlightStats>;
|
||||
HighlightType: HighlightType;
|
||||
HomeEdge: ResolverTypeWrapper<HomeEdge>;
|
||||
HomeError: ResolverTypeWrapper<HomeError>;
|
||||
HomeErrorCode: HomeErrorCode;
|
||||
HomeItem: ResolverTypeWrapper<HomeItem>;
|
||||
HomeItemSource: ResolverTypeWrapper<HomeItemSource>;
|
||||
HomeItemSourceType: HomeItemSourceType;
|
||||
HomeResult: ResolversTypes['HomeError'] | ResolversTypes['HomeSuccess'];
|
||||
HomeSection: ResolverTypeWrapper<HomeSection>;
|
||||
HomeSuccess: ResolverTypeWrapper<HomeSuccess>;
|
||||
ID: ResolverTypeWrapper<Scalars['ID']>;
|
||||
ImportFromIntegrationError: ResolverTypeWrapper<ImportFromIntegrationError>;
|
||||
ImportFromIntegrationErrorCode: ImportFromIntegrationErrorCode;
|
||||
|
|
@ -4421,7 +4514,8 @@ export type ResolversTypes = {
|
|||
SubscribeInput: SubscribeInput;
|
||||
SubscribeResult: ResolversTypes['SubscribeError'] | ResolversTypes['SubscribeSuccess'];
|
||||
SubscribeSuccess: ResolverTypeWrapper<SubscribeSuccess>;
|
||||
Subscription: ResolverTypeWrapper<{}>;
|
||||
Subscription: ResolverTypeWrapper<Subscription>;
|
||||
SubscriptionRootType: ResolverTypeWrapper<{}>;
|
||||
SubscriptionStatus: SubscriptionStatus;
|
||||
SubscriptionType: SubscriptionType;
|
||||
SubscriptionsError: ResolverTypeWrapper<SubscriptionsError>;
|
||||
|
|
@ -4727,6 +4821,13 @@ export type ResolversParentTypes = {
|
|||
Highlight: Highlight;
|
||||
HighlightReply: HighlightReply;
|
||||
HighlightStats: HighlightStats;
|
||||
HomeEdge: HomeEdge;
|
||||
HomeError: HomeError;
|
||||
HomeItem: HomeItem;
|
||||
HomeItemSource: HomeItemSource;
|
||||
HomeResult: ResolversParentTypes['HomeError'] | ResolversParentTypes['HomeSuccess'];
|
||||
HomeSection: HomeSection;
|
||||
HomeSuccess: HomeSuccess;
|
||||
ID: Scalars['ID'];
|
||||
ImportFromIntegrationError: ImportFromIntegrationError;
|
||||
ImportFromIntegrationResult: ResolversParentTypes['ImportFromIntegrationError'] | ResolversParentTypes['ImportFromIntegrationSuccess'];
|
||||
|
|
@ -4915,7 +5016,8 @@ export type ResolversParentTypes = {
|
|||
SubscribeInput: SubscribeInput;
|
||||
SubscribeResult: ResolversParentTypes['SubscribeError'] | ResolversParentTypes['SubscribeSuccess'];
|
||||
SubscribeSuccess: SubscribeSuccess;
|
||||
Subscription: {};
|
||||
Subscription: Subscription;
|
||||
SubscriptionRootType: {};
|
||||
SubscriptionsError: SubscriptionsError;
|
||||
SubscriptionsResult: ResolversParentTypes['SubscriptionsError'] | ResolversParentTypes['SubscriptionsSuccess'];
|
||||
SubscriptionsSuccess: SubscriptionsSuccess;
|
||||
|
|
@ -5915,6 +6017,67 @@ export type HighlightStatsResolvers<ContextType = ResolverContext, ParentType ex
|
|||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type HomeEdgeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeEdge'] = ResolversParentTypes['HomeEdge']> = {
|
||||
cursor?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
node?: Resolver<ResolversTypes['HomeSection'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type HomeErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeError'] = ResolversParentTypes['HomeError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['HomeErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type HomeItemResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeItem'] = ResolversParentTypes['HomeItem']> = {
|
||||
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
broadcastCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
canArchive?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
canComment?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
canDelete?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
canSave?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
canShare?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
date?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
dir?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
likeCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
previewContent?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
saveCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
seen_at?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
source?: Resolver<Maybe<ResolversTypes['HomeItemSource']>, ParentType, ContextType>;
|
||||
thumbnail?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
wordCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type HomeItemSourceResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeItemSource'] = ResolversParentTypes['HomeItemSource']> = {
|
||||
icon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
type?: Resolver<ResolversTypes['HomeItemSourceType'], ParentType, ContextType>;
|
||||
url?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type HomeResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeResult'] = ResolversParentTypes['HomeResult']> = {
|
||||
__resolveType: TypeResolveFn<'HomeError' | 'HomeSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type HomeSectionResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeSection'] = ResolversParentTypes['HomeSection']> = {
|
||||
items?: Resolver<Array<ResolversTypes['HomeItem']>, ParentType, ContextType>;
|
||||
layout?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
thumbnail?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
title?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type HomeSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HomeSuccess'] = ResolversParentTypes['HomeSuccess']> = {
|
||||
edges?: Resolver<Array<ResolversTypes['HomeEdge']>, ParentType, ContextType>;
|
||||
pageInfo?: Resolver<ResolversTypes['PageInfo'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ImportFromIntegrationErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ImportFromIntegrationError'] = ResolversParentTypes['ImportFromIntegrationError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['ImportFromIntegrationErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
|
|
@ -6313,6 +6476,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
|
|||
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
|
||||
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
|
||||
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
home?: Resolver<ResolversTypes['HomeResult'], ParentType, ContextType, Partial<QueryHomeArgs>>;
|
||||
integration?: Resolver<ResolversTypes['IntegrationResult'], ParentType, ContextType, RequireFields<QueryIntegrationArgs, 'name'>>;
|
||||
integrations?: Resolver<ResolversTypes['IntegrationsResult'], ParentType, ContextType>;
|
||||
labels?: Resolver<ResolversTypes['LabelsResult'], ParentType, ContextType>;
|
||||
|
|
@ -6898,28 +7062,33 @@ export type SubscribeSuccessResolvers<ContextType = ResolverContext, ParentType
|
|||
};
|
||||
|
||||
export type SubscriptionResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Subscription'] = ResolversParentTypes['Subscription']> = {
|
||||
autoAddToLibrary?: SubscriptionResolver<Maybe<ResolversTypes['Boolean']>, "autoAddToLibrary", ParentType, ContextType>;
|
||||
count?: SubscriptionResolver<ResolversTypes['Int'], "count", ParentType, ContextType>;
|
||||
createdAt?: SubscriptionResolver<ResolversTypes['Date'], "createdAt", ParentType, ContextType>;
|
||||
description?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "description", ParentType, ContextType>;
|
||||
failedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "failedAt", ParentType, ContextType>;
|
||||
fetchContent?: SubscriptionResolver<ResolversTypes['Boolean'], "fetchContent", ParentType, ContextType>;
|
||||
fetchContentType?: SubscriptionResolver<ResolversTypes['FetchContentType'], "fetchContentType", ParentType, ContextType>;
|
||||
folder?: SubscriptionResolver<ResolversTypes['String'], "folder", ParentType, ContextType>;
|
||||
icon?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "icon", ParentType, ContextType>;
|
||||
id?: SubscriptionResolver<ResolversTypes['ID'], "id", ParentType, ContextType>;
|
||||
isPrivate?: SubscriptionResolver<Maybe<ResolversTypes['Boolean']>, "isPrivate", ParentType, ContextType>;
|
||||
lastFetchedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "lastFetchedAt", ParentType, ContextType>;
|
||||
mostRecentItemDate?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "mostRecentItemDate", ParentType, ContextType>;
|
||||
name?: SubscriptionResolver<ResolversTypes['String'], "name", ParentType, ContextType>;
|
||||
newsletterEmail?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "newsletterEmail", ParentType, ContextType>;
|
||||
refreshedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "refreshedAt", ParentType, ContextType>;
|
||||
status?: SubscriptionResolver<ResolversTypes['SubscriptionStatus'], "status", ParentType, ContextType>;
|
||||
type?: SubscriptionResolver<ResolversTypes['SubscriptionType'], "type", ParentType, ContextType>;
|
||||
unsubscribeHttpUrl?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeHttpUrl", ParentType, ContextType>;
|
||||
unsubscribeMailTo?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "unsubscribeMailTo", ParentType, ContextType>;
|
||||
updatedAt?: SubscriptionResolver<Maybe<ResolversTypes['Date']>, "updatedAt", ParentType, ContextType>;
|
||||
url?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "url", ParentType, ContextType>;
|
||||
autoAddToLibrary?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
count?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
failedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
fetchContent?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
fetchContentType?: Resolver<ResolversTypes['FetchContentType'], ParentType, ContextType>;
|
||||
folder?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
icon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
isPrivate?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
lastFetchedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
mostRecentItemDate?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
newsletterEmail?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
refreshedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
status?: Resolver<ResolversTypes['SubscriptionStatus'], ParentType, ContextType>;
|
||||
type?: Resolver<ResolversTypes['SubscriptionType'], ParentType, ContextType>;
|
||||
unsubscribeHttpUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
unsubscribeMailTo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
updatedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
url?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SubscriptionRootTypeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionRootType'] = ResolversParentTypes['SubscriptionRootType']> = {
|
||||
hello?: SubscriptionResolver<Maybe<ResolversTypes['String']>, "hello", ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SubscriptionsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscriptionsError'] = ResolversParentTypes['SubscriptionsError']> = {
|
||||
|
|
@ -7491,6 +7660,13 @@ export type Resolvers<ContextType = ResolverContext> = {
|
|||
Highlight?: HighlightResolvers<ContextType>;
|
||||
HighlightReply?: HighlightReplyResolvers<ContextType>;
|
||||
HighlightStats?: HighlightStatsResolvers<ContextType>;
|
||||
HomeEdge?: HomeEdgeResolvers<ContextType>;
|
||||
HomeError?: HomeErrorResolvers<ContextType>;
|
||||
HomeItem?: HomeItemResolvers<ContextType>;
|
||||
HomeItemSource?: HomeItemSourceResolvers<ContextType>;
|
||||
HomeResult?: HomeResultResolvers<ContextType>;
|
||||
HomeSection?: HomeSectionResolvers<ContextType>;
|
||||
HomeSuccess?: HomeSuccessResolvers<ContextType>;
|
||||
ImportFromIntegrationError?: ImportFromIntegrationErrorResolvers<ContextType>;
|
||||
ImportFromIntegrationResult?: ImportFromIntegrationResultResolvers<ContextType>;
|
||||
ImportFromIntegrationSuccess?: ImportFromIntegrationSuccessResolvers<ContextType>;
|
||||
|
|
@ -7646,6 +7822,7 @@ export type Resolvers<ContextType = ResolverContext> = {
|
|||
SubscribeResult?: SubscribeResultResolvers<ContextType>;
|
||||
SubscribeSuccess?: SubscribeSuccessResolvers<ContextType>;
|
||||
Subscription?: SubscriptionResolvers<ContextType>;
|
||||
SubscriptionRootType?: SubscriptionRootTypeResolvers<ContextType>;
|
||||
SubscriptionsError?: SubscriptionsErrorResolvers<ContextType>;
|
||||
SubscriptionsResult?: SubscriptionsResultResolvers<ContextType>;
|
||||
SubscriptionsSuccess?: SubscriptionsSuccessResolvers<ContextType>;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
subscription: SubscriptionRootType
|
||||
}
|
||||
|
||||
directive @sanitize(allowedTags: [String], maxLength: Int, minLength: Int, pattern: String) on INPUT_FIELD_DEFINITION
|
||||
|
||||
type AddDiscoverFeedError {
|
||||
|
|
@ -1136,6 +1142,72 @@ enum HighlightType {
|
|||
REDACTION
|
||||
}
|
||||
|
||||
type HomeEdge {
|
||||
cursor: String!
|
||||
node: HomeSection!
|
||||
}
|
||||
|
||||
type HomeError {
|
||||
errorCodes: [HomeErrorCode!]!
|
||||
}
|
||||
|
||||
enum HomeErrorCode {
|
||||
BAD_REQUEST
|
||||
PENDING
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
type HomeItem {
|
||||
author: String
|
||||
broadcastCount: Int
|
||||
canArchive: Boolean
|
||||
canComment: Boolean
|
||||
canDelete: Boolean
|
||||
canSave: Boolean
|
||||
canShare: Boolean
|
||||
date: Date!
|
||||
dir: String
|
||||
id: ID!
|
||||
likeCount: Int
|
||||
previewContent: String
|
||||
saveCount: Int
|
||||
seen_at: Date
|
||||
source: HomeItemSource
|
||||
thumbnail: String
|
||||
title: String!
|
||||
url: String!
|
||||
wordCount: Int
|
||||
}
|
||||
|
||||
type HomeItemSource {
|
||||
icon: String
|
||||
id: ID
|
||||
name: String!
|
||||
type: HomeItemSourceType!
|
||||
url: String
|
||||
}
|
||||
|
||||
enum HomeItemSourceType {
|
||||
LIBRARY
|
||||
NEWSLETTER
|
||||
RECOMMENDATION
|
||||
RSS
|
||||
}
|
||||
|
||||
union HomeResult = HomeError | HomeSuccess
|
||||
|
||||
type HomeSection {
|
||||
items: [HomeItem!]!
|
||||
layout: String
|
||||
thumbnail: String
|
||||
title: String
|
||||
}
|
||||
|
||||
type HomeSuccess {
|
||||
edges: [HomeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type ImportFromIntegrationError {
|
||||
errorCodes: [ImportFromIntegrationErrorCode!]!
|
||||
}
|
||||
|
|
@ -1638,6 +1710,7 @@ type Query {
|
|||
getUserPersonalization: GetUserPersonalizationResult!
|
||||
groups: GroupsResult!
|
||||
hello: String
|
||||
home(after: String, first: Int): HomeResult!
|
||||
integration(name: String!): IntegrationResult!
|
||||
integrations: IntegrationsResult!
|
||||
labels: LabelsResult!
|
||||
|
|
@ -2541,6 +2614,10 @@ type Subscription {
|
|||
url: String
|
||||
}
|
||||
|
||||
type SubscriptionRootType {
|
||||
hello: String
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
ACTIVE
|
||||
DELETED
|
||||
|
|
|
|||
84
packages/api/src/jobs/score_library_item.ts
Normal file
84
packages/api/src/jobs/score_library_item.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
findLibraryItemById,
|
||||
updateLibraryItem,
|
||||
} from '../services/library_item'
|
||||
import { Feature, getScores } from '../services/score'
|
||||
import { lanaugeToCode } from '../utils/helpers'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
export const SCORE_LIBRARY_ITEM_JOB = 'SCORE_LIBRARY_ITEM_JOB'
|
||||
|
||||
export interface ScoreLibraryItemJobData {
|
||||
userId: string
|
||||
libraryItemId: string
|
||||
}
|
||||
|
||||
export const scoreLibraryItem = async (
|
||||
data: ScoreLibraryItemJobData
|
||||
): Promise<void> => {
|
||||
logger.info('Scoring library item', data)
|
||||
|
||||
const { userId, libraryItemId } = data
|
||||
|
||||
const libraryItem = await findLibraryItemById(libraryItemId, userId, {
|
||||
select: [
|
||||
'id',
|
||||
'title',
|
||||
'thumbnail',
|
||||
'siteIcon',
|
||||
'savedAt',
|
||||
'siteName',
|
||||
'directionality',
|
||||
'folder',
|
||||
'author',
|
||||
'itemLanguage',
|
||||
'wordCount',
|
||||
],
|
||||
})
|
||||
if (!libraryItem) {
|
||||
logger.error('Library item not found', data)
|
||||
return
|
||||
}
|
||||
|
||||
const itemFeatures = {
|
||||
[libraryItem.id]: {
|
||||
library_item_id: libraryItem.id,
|
||||
title: libraryItem.title,
|
||||
has_thumbnail: !!libraryItem.thumbnail,
|
||||
has_site_icon: !!libraryItem.siteIcon,
|
||||
saved_at: libraryItem.savedAt,
|
||||
site: libraryItem.siteName,
|
||||
directionality: libraryItem.directionality,
|
||||
folder: libraryItem.folder,
|
||||
subscription_type: 'library',
|
||||
author: libraryItem.author,
|
||||
language: lanaugeToCode(libraryItem.itemLanguage || 'English'),
|
||||
word_count: libraryItem.wordCount,
|
||||
published_at: libraryItem.publishedAt,
|
||||
subscription: libraryItem.subscription,
|
||||
} as Feature,
|
||||
}
|
||||
|
||||
const scores = await getScores({
|
||||
user_id: userId,
|
||||
items: itemFeatures,
|
||||
})
|
||||
|
||||
logger.info('Scores', scores)
|
||||
const score = scores[libraryItem.id]
|
||||
if (!score) {
|
||||
logger.error('Failed to score library item', data)
|
||||
throw new Error('Failed to score library item')
|
||||
}
|
||||
|
||||
await updateLibraryItem(
|
||||
libraryItem.id,
|
||||
{
|
||||
score,
|
||||
},
|
||||
userId,
|
||||
undefined,
|
||||
true
|
||||
)
|
||||
logger.info('Library item scored', data)
|
||||
}
|
||||
408
packages/api/src/jobs/update_home.ts
Normal file
408
packages/api/src/jobs/update_home.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
import { LibraryItem } from '../entity/library_item'
|
||||
import { PublicItem } from '../entity/public_item'
|
||||
import { Subscription } from '../entity/subscription'
|
||||
import { User } from '../entity/user'
|
||||
import { redisDataSource } from '../redis_data_source'
|
||||
import { findUnseenPublicItems } from '../services/home'
|
||||
import { searchLibraryItems } from '../services/library_item'
|
||||
import { Feature, getScores, ScoreApiResponse } from '../services/score'
|
||||
import { findSubscriptionsByNames } from '../services/subscriptions'
|
||||
import { findActiveUser } from '../services/user'
|
||||
import { lanaugeToCode } from '../utils/helpers'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
export const UPDATE_HOME_JOB = 'UPDATE_HOME_JOB'
|
||||
|
||||
export interface UpdateHomeJobData {
|
||||
userId: string
|
||||
cursor?: number
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
type: string
|
||||
thumbnail?: string
|
||||
previewContent?: string
|
||||
languageCode: string
|
||||
author?: string
|
||||
dir: string
|
||||
date: Date
|
||||
topic?: string
|
||||
wordCount: number
|
||||
siteIcon?: string
|
||||
siteName?: string
|
||||
folder?: string
|
||||
score?: number
|
||||
publishedAt?: Date
|
||||
subscription?: {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface Section {
|
||||
items: Array<Item>
|
||||
layout: string
|
||||
}
|
||||
|
||||
const libraryItemToCandidate = (
|
||||
item: LibraryItem,
|
||||
subscriptions: Array<Subscription>
|
||||
): Candidate => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: item.originalUrl,
|
||||
type: 'library_item',
|
||||
thumbnail: item.thumbnail || undefined,
|
||||
previewContent: item.description || undefined,
|
||||
languageCode: lanaugeToCode(item.itemLanguage || 'English'),
|
||||
author: item.author || undefined,
|
||||
dir: item.directionality || 'ltr',
|
||||
date: item.createdAt,
|
||||
topic: item.topic,
|
||||
wordCount: item.wordCount || 0,
|
||||
siteName: item.siteName || undefined,
|
||||
siteIcon: item.siteIcon || undefined,
|
||||
folder: item.folder,
|
||||
score: item.score,
|
||||
publishedAt: item.publishedAt || undefined,
|
||||
subscription: subscriptions.find(
|
||||
(subscription) =>
|
||||
subscription.name === item.subscription ||
|
||||
subscription.url === item.subscription
|
||||
),
|
||||
})
|
||||
|
||||
const publicItemToCandidate = (item: PublicItem): Candidate => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
type: 'public_item',
|
||||
thumbnail: item.thumbnail,
|
||||
previewContent: item.previewContent,
|
||||
languageCode: item.languageCode || 'en',
|
||||
author: item.author,
|
||||
dir: item.dir || 'ltr',
|
||||
date: item.createdAt,
|
||||
topic: item.topic,
|
||||
wordCount: item.wordCount || 0,
|
||||
siteIcon: item.siteIcon,
|
||||
siteName: item.siteName,
|
||||
publishedAt: item.publishedAt,
|
||||
subscription: {
|
||||
name: item.source.name,
|
||||
type: item.source.type,
|
||||
},
|
||||
})
|
||||
|
||||
const selectCandidates = async (user: User): Promise<Array<Candidate>> => {
|
||||
const userId = user.id
|
||||
// get last 100 library items saved and not seen by user
|
||||
const libraryItems = await searchLibraryItems(
|
||||
{
|
||||
size: 100,
|
||||
includeContent: false,
|
||||
query: `-is:seen wordsCount:>0`,
|
||||
},
|
||||
userId
|
||||
)
|
||||
|
||||
logger.info(`Found ${libraryItems.length} library items`)
|
||||
|
||||
// get subscriptions for the library items
|
||||
const subscriptionNames = libraryItems
|
||||
.filter((item) => !!item.subscription)
|
||||
.map((item) => item.subscription as string)
|
||||
|
||||
const subscriptions = await findSubscriptionsByNames(
|
||||
userId,
|
||||
subscriptionNames
|
||||
)
|
||||
|
||||
// map library items to candidates and limit to 70
|
||||
const privateCandidates: Array<Candidate> = libraryItems
|
||||
.map((item) => libraryItemToCandidate(item, subscriptions))
|
||||
.slice(0, 70)
|
||||
const privateCandidatesSize = privateCandidates.length
|
||||
|
||||
logger.info(`Found ${privateCandidatesSize} private candidates`)
|
||||
|
||||
// get 100 items not seen by the user from public inventory
|
||||
const publicItems = await findUnseenPublicItems(userId, {
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
logger.info(`Found ${publicItems.length} public items`)
|
||||
|
||||
// map public items to candidates and limit to the remaining vacancies
|
||||
const publicCandidates: Array<Candidate> = publicItems
|
||||
.map(publicItemToCandidate)
|
||||
.slice(0, 100 - privateCandidatesSize)
|
||||
|
||||
const publicCandidatesSize = publicCandidates.length
|
||||
logger.info(`Found ${publicCandidatesSize} public candidates`)
|
||||
|
||||
// returns 100 candidates which are a mix of private and public candidates
|
||||
return [...privateCandidates, ...publicCandidates]
|
||||
}
|
||||
|
||||
const rankCandidates = async (
|
||||
userId: string,
|
||||
candidates: Array<Candidate>
|
||||
): Promise<Array<Candidate>> => {
|
||||
if (candidates.length <= 10) {
|
||||
// no need to rank if there are less than 10 candidates
|
||||
return candidates
|
||||
}
|
||||
|
||||
const unscoredCandidates = candidates.filter(
|
||||
(item) => item.score === undefined
|
||||
)
|
||||
|
||||
const data = {
|
||||
user_id: userId,
|
||||
items: unscoredCandidates.reduce((acc, item) => {
|
||||
acc[item.id] = {
|
||||
library_item_id: item.id,
|
||||
title: item.title,
|
||||
has_thumbnail: !!item.thumbnail,
|
||||
has_site_icon: !!item.siteIcon,
|
||||
saved_at: item.date,
|
||||
site: item.siteName,
|
||||
language: item.languageCode,
|
||||
directionality: item.dir,
|
||||
folder: item.folder,
|
||||
subscription_type: item.subscription?.type,
|
||||
author: item.author,
|
||||
word_count: item.wordCount,
|
||||
published_at: item.publishedAt,
|
||||
subscription: item.subscription?.name,
|
||||
} as Feature
|
||||
return acc
|
||||
}, {} as Record<string, Feature>),
|
||||
}
|
||||
|
||||
const newScores = await getScores(data)
|
||||
const preCalculatedScores = candidates
|
||||
.filter((item) => item.score !== undefined)
|
||||
.reduce((acc, item) => {
|
||||
acc[item.id] = item.score as number
|
||||
return acc
|
||||
}, {} as ScoreApiResponse)
|
||||
const scores = { ...preCalculatedScores, ...newScores }
|
||||
|
||||
// rank candidates by score in ascending order
|
||||
candidates.sort((a, b) => {
|
||||
const scoreA = scores[a.id] || 0
|
||||
const scoreB = scores[b.id] || 0
|
||||
|
||||
return scoreA - scoreB
|
||||
})
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
const redisKey = (userId: string) => `just-read-feed:${userId}`
|
||||
const MAX_FEED_ITEMS = 500
|
||||
|
||||
export const getHomeSections = async (
|
||||
userId: string,
|
||||
limit: number,
|
||||
maxScore?: number
|
||||
): Promise<Array<{ member: Section; score: number }>> => {
|
||||
const redisClient = redisDataSource.redisClient
|
||||
if (!redisClient) {
|
||||
throw new Error('Redis client not available')
|
||||
}
|
||||
|
||||
const key = redisKey(userId)
|
||||
|
||||
// get feed items from redis sorted set in descending order
|
||||
// with score smalled than maxScore
|
||||
// limit to the first `limit` items
|
||||
// response is an array of [member1, score1, member2, score2, ...]
|
||||
const results = await redisClient.zrevrangebyscore(
|
||||
key,
|
||||
maxScore ? maxScore - 1 : '+inf',
|
||||
'-inf',
|
||||
'WITHSCORES',
|
||||
'LIMIT',
|
||||
0,
|
||||
limit
|
||||
)
|
||||
|
||||
const sections = []
|
||||
for (let i = 0; i < results.length; i += 2) {
|
||||
const member = JSON.parse(results[i]) as Section
|
||||
const score = Number(results[i + 1])
|
||||
sections.push({ member, score })
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
const appendSectionsToHome = async (
|
||||
userId: string,
|
||||
sections: Array<Section>,
|
||||
cursor = Date.now()
|
||||
) => {
|
||||
const redisClient = redisDataSource.redisClient
|
||||
if (!redisClient) {
|
||||
throw new Error('Redis client not available')
|
||||
}
|
||||
|
||||
const key = redisKey(userId)
|
||||
|
||||
// store candidates in redis sorted set
|
||||
const pipeline = redisClient.pipeline()
|
||||
|
||||
const offset = sections.length + 86_400_000
|
||||
cursor = cursor - offset
|
||||
|
||||
const scoreMembers = sections.flatMap((section, index) => [
|
||||
cursor + index + 86_400_000, // sections expire in 24 hours
|
||||
JSON.stringify(section),
|
||||
])
|
||||
|
||||
// add section to the sorted set
|
||||
pipeline.zadd(key, ...scoreMembers)
|
||||
|
||||
// remove expired sections and keep only the top 500
|
||||
pipeline.zremrangebyrank(key, 0, -(MAX_FEED_ITEMS + 1))
|
||||
pipeline.zremrangebyscore(key, '-inf', Date.now())
|
||||
|
||||
logger.info('Adding home sections to redis')
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
const mixHomeItems = (rankedHomeItems: Array<Candidate>): Array<Section> => {
|
||||
// find the median word count
|
||||
const wordCounts = rankedHomeItems.map((item) => item.wordCount)
|
||||
wordCounts.sort((a, b) => a - b)
|
||||
const medianWordCount = wordCounts[Math.floor(wordCounts.length / 2)]
|
||||
// separate items into two groups based on word count
|
||||
const shortItems: Array<Candidate> = []
|
||||
const longItems: Array<Candidate> = []
|
||||
for (const item of rankedHomeItems) {
|
||||
if (item.wordCount < medianWordCount) {
|
||||
shortItems.push(item)
|
||||
} else {
|
||||
longItems.push(item)
|
||||
}
|
||||
}
|
||||
// initialize empty batches
|
||||
const batches: Array<Array<Candidate>> = Array.from(
|
||||
{ length: Math.floor(rankedHomeItems.length / 10) },
|
||||
() => []
|
||||
)
|
||||
|
||||
const checkConstraints = (batch: Array<Candidate>, item: Candidate) => {
|
||||
const titleCount = batch.filter((i) => i.title === item.title).length
|
||||
const authorCount = batch.filter((i) => i.author === item.author).length
|
||||
const siteCount = batch.filter((i) => i.siteName === item.siteName).length
|
||||
const subscriptionCount = batch.filter(
|
||||
(i) => i.subscription?.name === item.subscription?.name
|
||||
).length
|
||||
|
||||
return (
|
||||
titleCount < 1 &&
|
||||
authorCount < 2 &&
|
||||
siteCount < 2 &&
|
||||
subscriptionCount < 2
|
||||
)
|
||||
}
|
||||
|
||||
const distributeItems = (
|
||||
items: Array<Candidate>,
|
||||
batches: Array<Array<Candidate>>
|
||||
) => {
|
||||
for (const item of items) {
|
||||
let added = false
|
||||
for (const batch of batches) {
|
||||
if (batch.length < 5 && checkConstraints(batch, item)) {
|
||||
batch.push(item)
|
||||
added = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!added) {
|
||||
for (const batch of batches) {
|
||||
if (batch.length < 10) {
|
||||
batch.push(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// distribute quick link items first
|
||||
distributeItems(shortItems, batches)
|
||||
distributeItems(longItems, batches)
|
||||
|
||||
// convert batches to sections
|
||||
const sections = []
|
||||
for (const batch of batches) {
|
||||
// create a section for all quick links
|
||||
sections.push({
|
||||
items: batch.slice(0, 5).map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
})),
|
||||
layout: 'quick links',
|
||||
})
|
||||
|
||||
// create a section for each long item
|
||||
sections.push(
|
||||
...batch.slice(5).map((item) => ({
|
||||
items: [{ id: item.id, type: item.type }],
|
||||
layout: 'long',
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export const updateHome = async (data: UpdateHomeJobData) => {
|
||||
const { userId, cursor } = data
|
||||
logger.info('Updating home for user', data)
|
||||
|
||||
const user = await findActiveUser(userId)
|
||||
if (!user) {
|
||||
logger.error(`User ${userId} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Updating home for user ${userId}`)
|
||||
|
||||
const candidates = await selectCandidates(user)
|
||||
logger.info(`Found ${candidates.length} candidates`)
|
||||
|
||||
// TODO: integrity check on candidates
|
||||
|
||||
logger.info('Ranking candidates')
|
||||
const rankedCandidates = await rankCandidates(userId, candidates)
|
||||
if (rankedCandidates.length === 0) {
|
||||
logger.info('No candidates found')
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: filter candidates
|
||||
|
||||
logger.info('Mix home items to create sections')
|
||||
const rankedSections = mixHomeItems(rankedCandidates)
|
||||
logger.info(`Created ${rankedSections.length} sections`)
|
||||
|
||||
logger.info('Appending sections to home')
|
||||
await appendSectionsToHome(userId, rankedSections, cursor)
|
||||
logger.info('Home updated for user', { userId })
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { env } from './env'
|
|||
import { ReportType } from './generated/graphql'
|
||||
import {
|
||||
enqueueProcessYouTubeVideo,
|
||||
enqueueScoreJob,
|
||||
enqueueTriggerRuleJob,
|
||||
} from './utils/createTask'
|
||||
import { logger } from './utils/logger'
|
||||
|
|
@ -74,6 +75,11 @@ export const createPubSubClient = (): PubsubClient => {
|
|||
libraryItemId: data.id,
|
||||
})
|
||||
}
|
||||
|
||||
await enqueueScoreJob({
|
||||
userId,
|
||||
libraryItemId: data.id,
|
||||
})
|
||||
}
|
||||
},
|
||||
entityUpdated: async <T extends EntityEvent>(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ import {
|
|||
import { refreshAllFeeds } from './jobs/rss/refreshAllFeeds'
|
||||
import { refreshFeed } from './jobs/rss/refreshFeed'
|
||||
import { savePageJob } from './jobs/save_page'
|
||||
import {
|
||||
scoreLibraryItem,
|
||||
SCORE_LIBRARY_ITEM_JOB,
|
||||
} from './jobs/score_library_item'
|
||||
import {
|
||||
syncReadPositionsJob,
|
||||
SYNC_READ_POSITIONS_JOB_NAME,
|
||||
|
|
@ -59,6 +63,7 @@ import {
|
|||
UPDATE_HIGHLIGHT_JOB,
|
||||
UPDATE_LABELS_JOB,
|
||||
} from './jobs/update_db'
|
||||
import { updateHome, UPDATE_HOME_JOB } from './jobs/update_home'
|
||||
import { updatePDFContentJob } from './jobs/update_pdf_content'
|
||||
import { uploadContentJob, UPLOAD_CONTENT_JOB } from './jobs/upload_content'
|
||||
import { redisDataSource } from './redis_data_source'
|
||||
|
|
@ -185,6 +190,10 @@ export const createWorker = (connection: ConnectionOptions) =>
|
|||
return createDigest(job.data)
|
||||
case UPLOAD_CONTENT_JOB:
|
||||
return uploadContentJob(job.data)
|
||||
case UPDATE_HOME_JOB:
|
||||
return updateHome(job.data)
|
||||
case SCORE_LIBRARY_ITEM_JOB:
|
||||
return scoreLibraryItem(job.data)
|
||||
default:
|
||||
logger.warning(`[queue-processor] unhandled job: ${job.name}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { createHmac } from 'crypto'
|
||||
import { isError } from 'lodash'
|
||||
import { Highlight as HighlightEntity } from '../entity/highlight'
|
||||
import { LibraryItem } from '../entity/library_item'
|
||||
import {
|
||||
EXISTING_NEWSLETTER_FOLDER,
|
||||
NewsletterEmail,
|
||||
} from '../entity/newsletter_email'
|
||||
import { PublicItem } from '../entity/public_item'
|
||||
import {
|
||||
DEFAULT_SUBSCRIPTION_FOLDER,
|
||||
Subscription,
|
||||
|
|
@ -17,6 +20,9 @@ import { env } from '../env'
|
|||
import {
|
||||
Article,
|
||||
Highlight,
|
||||
HomeItem,
|
||||
HomeItemSource,
|
||||
HomeItemSourceType,
|
||||
Label,
|
||||
PageType,
|
||||
Recommendation,
|
||||
|
|
@ -25,6 +31,7 @@ import {
|
|||
} from '../generated/graphql'
|
||||
import { getAISummary } from '../services/ai-summaries'
|
||||
import { findUserFeatures } from '../services/features'
|
||||
import { Merge } from '../util'
|
||||
import {
|
||||
highlightDataToHighlight,
|
||||
isBase64Image,
|
||||
|
|
@ -33,7 +40,6 @@ import {
|
|||
wordsCount,
|
||||
} from '../utils/helpers'
|
||||
import { createImageProxyUrl } from '../utils/imageproxy'
|
||||
import { logger } from '../utils/logger'
|
||||
import { contentConverter } from '../utils/parser'
|
||||
import {
|
||||
generateDownloadSignedUrl,
|
||||
|
|
@ -54,6 +60,7 @@ import {
|
|||
saveDiscoverArticleResolver,
|
||||
} from './discover_feeds'
|
||||
import { optInFeatureResolver } from './features'
|
||||
import { homeResolver } from './home'
|
||||
import { uploadImportFileResolver } from './importers/uploadImportFileResolver'
|
||||
import {
|
||||
addPopularReadResolver,
|
||||
|
|
@ -360,6 +367,7 @@ export const functionResolvers = {
|
|||
feeds: feedsResolver,
|
||||
scanFeeds: scanFeedsResolver,
|
||||
integration: integrationResolver,
|
||||
home: homeResolver,
|
||||
},
|
||||
User: {
|
||||
async intercomHash(
|
||||
|
|
@ -623,6 +631,108 @@ export const functionResolvers = {
|
|||
return newsletterEmail.folder || EXISTING_NEWSLETTER_FOLDER
|
||||
},
|
||||
},
|
||||
HomeSection: {
|
||||
async items(
|
||||
section: {
|
||||
items: Array<{ id: string; type: 'library_item' | 'public_item' }>
|
||||
},
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
const libraryItemIds = section.items
|
||||
.filter((item) => item.type === 'library_item')
|
||||
.map((item) => item.id)
|
||||
const libraryItems = (
|
||||
await ctx.dataLoaders.libraryItems.loadMany(libraryItemIds)
|
||||
).filter((libraryItem) => !isError(libraryItem)) as Array<LibraryItem>
|
||||
|
||||
const publicItemIds = section.items
|
||||
.filter((item) => item.type === 'public_item')
|
||||
.map((item) => item.id)
|
||||
const publicItems = (
|
||||
await ctx.dataLoaders.publicItems.loadMany(publicItemIds)
|
||||
).filter((publicItem) => !isError(publicItem)) as Array<PublicItem>
|
||||
|
||||
return libraryItems
|
||||
.map(
|
||||
(libraryItem) =>
|
||||
({
|
||||
id: libraryItem.id,
|
||||
title: libraryItem.title,
|
||||
author: libraryItem.author,
|
||||
thumbnail: libraryItem.thumbnail,
|
||||
wordCount: libraryItem.wordCount,
|
||||
date: libraryItem.savedAt,
|
||||
url: libraryItem.originalUrl,
|
||||
canArchive: !libraryItem.archivedAt,
|
||||
canDelete: !libraryItem.deletedAt,
|
||||
canSave: false,
|
||||
dir: libraryItem.directionality,
|
||||
previewContent: libraryItem.description,
|
||||
subscription: libraryItem.subscription,
|
||||
siteName: libraryItem.siteName,
|
||||
siteIcon: libraryItem.siteIcon,
|
||||
} as HomeItem)
|
||||
)
|
||||
.concat(
|
||||
publicItems.map(
|
||||
(publicItem) =>
|
||||
({
|
||||
id: publicItem.id,
|
||||
title: publicItem.title,
|
||||
author: publicItem.author,
|
||||
dir: publicItem.dir,
|
||||
previewContent: publicItem.previewContent,
|
||||
thumbnail: publicItem.thumbnail,
|
||||
wordCount: publicItem.wordCount,
|
||||
date: publicItem.createdAt,
|
||||
url: publicItem.url,
|
||||
canArchive: false,
|
||||
canDelete: false,
|
||||
canSave: true,
|
||||
broadcastCount: publicItem.stats.broadcastCount,
|
||||
likeCount: publicItem.stats.likeCount,
|
||||
saveCount: publicItem.stats.saveCount,
|
||||
source: publicItem.source,
|
||||
} as HomeItem)
|
||||
)
|
||||
)
|
||||
},
|
||||
},
|
||||
HomeItem: {
|
||||
async source(
|
||||
item: Merge<
|
||||
HomeItem,
|
||||
{ subscription?: string; siteName: string; siteIcon?: string }
|
||||
>,
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
): Promise<HomeItemSource> {
|
||||
if (item.source) {
|
||||
return item.source
|
||||
}
|
||||
|
||||
if (!item.subscription) {
|
||||
return {
|
||||
name: item.siteName,
|
||||
icon: item.siteIcon,
|
||||
type: HomeItemSourceType.Library,
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = await ctx.dataLoaders.subscriptions.load(
|
||||
item.subscription
|
||||
)
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
url: subscription.url,
|
||||
name: subscription.name,
|
||||
icon: subscription.icon,
|
||||
type: subscription.type as unknown as HomeItemSourceType,
|
||||
}
|
||||
},
|
||||
},
|
||||
...resultResolveTypeResolver('Login'),
|
||||
...resultResolveTypeResolver('LogOut'),
|
||||
...resultResolveTypeResolver('GoogleSignup'),
|
||||
|
|
@ -722,4 +832,5 @@ export const functionResolvers = {
|
|||
...resultResolveTypeResolver('Integration'),
|
||||
...resultResolveTypeResolver('ExportToIntegration'),
|
||||
...resultResolveTypeResolver('ReplyToEmail'),
|
||||
...resultResolveTypeResolver('Home'),
|
||||
}
|
||||
|
|
|
|||
71
packages/api/src/resolvers/home/index.ts
Normal file
71
packages/api/src/resolvers/home/index.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import {
|
||||
HomeError,
|
||||
HomeErrorCode,
|
||||
HomeItem,
|
||||
HomeSection,
|
||||
HomeSuccess,
|
||||
QueryHomeArgs,
|
||||
} from '../../generated/graphql'
|
||||
import { getHomeSections } from '../../jobs/update_home'
|
||||
import { getJob } from '../../queue-processor'
|
||||
import { Merge } from '../../util'
|
||||
import { enqueueUpdateHomeJob, updateHomeJobId } from '../../utils/createTask'
|
||||
import { authorized } from '../../utils/gql-utils'
|
||||
|
||||
type PartialHomeItem = Merge<Partial<HomeItem>, { type: string }>
|
||||
type PartialHomeSection = Merge<HomeSection, { items: Array<PartialHomeItem> }>
|
||||
type PartialHomeSuccess = Merge<
|
||||
HomeSuccess,
|
||||
{
|
||||
edges: Array<{ cursor: string; node: PartialHomeSection }>
|
||||
}
|
||||
>
|
||||
// This resolver is used to fetch the just read feed for the user.
|
||||
// when the feed is empty, it enqueues a job to update the feed.
|
||||
// when client tries to fetch more then the feed has, it enqueues a job to update the feed.
|
||||
export const homeResolver = authorized<
|
||||
PartialHomeSuccess,
|
||||
HomeError,
|
||||
QueryHomeArgs
|
||||
>(async (_, { first, after }, { uid, log }) => {
|
||||
const limit = first || 6
|
||||
const cursor = after ? parseInt(after) : undefined
|
||||
|
||||
const sections = await getHomeSections(uid, limit, cursor)
|
||||
log.info('Just read feed sections fetched')
|
||||
|
||||
if (sections.length === 0) {
|
||||
const existingJob = await getJob(updateHomeJobId(uid))
|
||||
if (existingJob) {
|
||||
log.info('Just read feed update job already enqueued')
|
||||
|
||||
return {
|
||||
errorCodes: [HomeErrorCode.Pending],
|
||||
}
|
||||
}
|
||||
|
||||
await enqueueUpdateHomeJob({
|
||||
userId: uid,
|
||||
cursor,
|
||||
})
|
||||
|
||||
log.info('Just read feed update enqueued')
|
||||
|
||||
return {
|
||||
errorCodes: [HomeErrorCode.Pending],
|
||||
}
|
||||
}
|
||||
|
||||
const edges = sections.map((section) => ({
|
||||
cursor: section.score.toString(),
|
||||
node: section.member,
|
||||
}))
|
||||
|
||||
return {
|
||||
edges,
|
||||
pageInfo: {
|
||||
hasPreviousPage: true, // there is always a previous page for new items
|
||||
hasNextPage: true, // there is always a next page for old items
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -8,8 +8,12 @@ import winston from 'winston'
|
|||
import { ReadingProgressDataSource } from '../datasources/reading_progress_data_source'
|
||||
import { Highlight } from '../entity/highlight'
|
||||
import { Label } from '../entity/label'
|
||||
import { LibraryItem } from '../entity/library_item'
|
||||
import { PublicItem } from '../entity/public_item'
|
||||
import { Recommendation } from '../entity/recommendation'
|
||||
import { Subscription } from '../entity/subscription'
|
||||
import { UploadFile } from '../entity/upload_file'
|
||||
import { HomeItem } from '../generated/graphql'
|
||||
import { PubsubClient } from '../pubsub'
|
||||
|
||||
export interface Claims {
|
||||
|
|
@ -51,6 +55,9 @@ export interface RequestContext {
|
|||
highlights: DataLoader<string, Highlight[]>
|
||||
recommendations: DataLoader<string, Recommendation[]>
|
||||
uploadFiles: DataLoader<string, UploadFile | undefined>
|
||||
libraryItems: DataLoader<string, LibraryItem>
|
||||
publicItems: DataLoader<string, PublicItem>
|
||||
subscriptions: DataLoader<string, Subscription>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3101,6 +3101,76 @@ const schema = gql`
|
|||
SUBSCRIBE
|
||||
}
|
||||
|
||||
enum HomeItemSourceType {
|
||||
RSS
|
||||
NEWSLETTER
|
||||
RECOMMENDATION
|
||||
LIBRARY
|
||||
}
|
||||
|
||||
type HomeItemSource {
|
||||
id: ID
|
||||
name: String!
|
||||
url: String
|
||||
icon: String
|
||||
type: HomeItemSourceType!
|
||||
}
|
||||
|
||||
type HomeItem {
|
||||
id: ID!
|
||||
title: String!
|
||||
url: String!
|
||||
thumbnail: String
|
||||
previewContent: String
|
||||
saveCount: Int
|
||||
likeCount: Int
|
||||
broadcastCount: Int
|
||||
date: Date!
|
||||
author: String
|
||||
dir: String
|
||||
seen_at: Date
|
||||
wordCount: Int
|
||||
source: HomeItemSource
|
||||
canSave: Boolean
|
||||
canComment: Boolean
|
||||
canShare: Boolean
|
||||
canArchive: Boolean
|
||||
canDelete: Boolean
|
||||
}
|
||||
|
||||
type HomeSection {
|
||||
title: String
|
||||
layout: String
|
||||
items: [HomeItem!]!
|
||||
thumbnail: String
|
||||
}
|
||||
|
||||
type HomeEdge {
|
||||
cursor: String!
|
||||
node: HomeSection!
|
||||
}
|
||||
|
||||
type HomeSuccess {
|
||||
edges: [HomeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
enum HomeErrorCode {
|
||||
UNAUTHORIZED
|
||||
BAD_REQUEST
|
||||
PENDING
|
||||
}
|
||||
|
||||
type HomeError {
|
||||
errorCodes: [HomeErrorCode!]!
|
||||
}
|
||||
|
||||
union HomeResult = HomeSuccess | HomeError
|
||||
|
||||
type SubscriptionRootType {
|
||||
hello: String # for testing only
|
||||
}
|
||||
|
||||
# Mutations
|
||||
type Mutation {
|
||||
googleLogin(input: GoogleLoginInput!): LoginResult!
|
||||
|
|
@ -3296,6 +3366,13 @@ const schema = gql`
|
|||
feeds(input: FeedsInput!): FeedsResult!
|
||||
discoverFeeds: DiscoverFeedResult!
|
||||
scanFeeds(input: ScanFeedsInput!): ScanFeedsResult!
|
||||
home(first: Int, after: String): HomeResult!
|
||||
}
|
||||
|
||||
schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
subscription: SubscriptionRootType
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
113
packages/api/src/services/home.ts
Normal file
113
packages/api/src/services/home.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { PublicItem } from '../entity/public_item'
|
||||
import { HomeItem } from '../generated/graphql'
|
||||
import { authTrx } from '../repository'
|
||||
import { findLibraryItemsByIds } from './library_item'
|
||||
|
||||
export const batchGetPublicItems = async (
|
||||
ids: readonly string[]
|
||||
): Promise<Array<PublicItem>> => {
|
||||
return authTrx(async (tx) =>
|
||||
tx
|
||||
.getRepository(PublicItem)
|
||||
.createQueryBuilder('public_item')
|
||||
.where('public_item.id IN (:...ids)', { ids })
|
||||
.getMany()
|
||||
)
|
||||
}
|
||||
|
||||
export const batchGetHomeItems = async (
|
||||
ids: readonly string[]
|
||||
): Promise<Array<HomeItem>> => {
|
||||
const libraryItems = await findLibraryItemsByIds(ids as string[])
|
||||
|
||||
const publicItems = await authTrx(async (tx) =>
|
||||
tx
|
||||
.getRepository(PublicItem)
|
||||
.createQueryBuilder('public_item')
|
||||
.innerJoin(
|
||||
'public_item_stats',
|
||||
'stats',
|
||||
'stats.public_item_id = public_item.id'
|
||||
)
|
||||
.innerJoin(
|
||||
'public_item_source',
|
||||
'source',
|
||||
'source.id = public_item.source_id'
|
||||
)
|
||||
.where('public_item.id IN (:...ids)', { ids })
|
||||
.getMany()
|
||||
)
|
||||
|
||||
return ids
|
||||
.map((id) => {
|
||||
const libraryItem = libraryItems.find((li) => li.id === id)
|
||||
if (libraryItem) {
|
||||
return {
|
||||
...libraryItem,
|
||||
date: libraryItem.savedAt,
|
||||
url: libraryItem.originalUrl,
|
||||
canArchive: !libraryItem.archivedAt,
|
||||
canDelete: !libraryItem.deletedAt,
|
||||
canSave: false,
|
||||
dir: libraryItem.directionality,
|
||||
subscription: null,
|
||||
previewContent: libraryItem.description,
|
||||
} as HomeItem
|
||||
} else {
|
||||
const publicItem = publicItems.find((pi) => pi.id === id)
|
||||
return publicItem
|
||||
? ({
|
||||
...publicItem,
|
||||
date: publicItem.createdAt,
|
||||
url: publicItem.url,
|
||||
canArchive: false,
|
||||
canDelete: false,
|
||||
canSave: true,
|
||||
broadcastCount: publicItem.stats.broadcastCount,
|
||||
likeCount: publicItem.stats.likeCount,
|
||||
saveCount: publicItem.stats.saveCount,
|
||||
subscription: publicItem.source,
|
||||
} as HomeItem)
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
.filter((item) => item !== undefined) as Array<HomeItem>
|
||||
}
|
||||
|
||||
export const findUnseenPublicItems = async (
|
||||
userId: string,
|
||||
options: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
): Promise<Array<PublicItem>> => {
|
||||
return authTrx(
|
||||
async (tx) =>
|
||||
tx
|
||||
.getRepository(PublicItem)
|
||||
.createQueryBuilder('public_item')
|
||||
.leftJoin(
|
||||
'public_item_interactions',
|
||||
'interaction',
|
||||
'interaction.public_item_id = public_item.id'
|
||||
)
|
||||
.innerJoin(
|
||||
'public_item_stats',
|
||||
'stats',
|
||||
'stats.public_item_id = public_item.id'
|
||||
)
|
||||
.innerJoin(
|
||||
'public_item_source',
|
||||
'source',
|
||||
'source.id = public_item.source_id'
|
||||
)
|
||||
.where('interaction.user_id = :userId', { userId })
|
||||
.andWhere('interaction.seen_at IS NULL')
|
||||
.orderBy('public_item.createdAt', 'DESC')
|
||||
.take(options.limit)
|
||||
.skip(options.offset)
|
||||
.getMany(),
|
||||
undefined,
|
||||
userId
|
||||
)
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ enum ReadFilter {
|
|||
READ = 'read',
|
||||
READING = 'reading',
|
||||
UNREAD = 'unread',
|
||||
SEEN = 'seen',
|
||||
}
|
||||
|
||||
enum InFilter {
|
||||
|
|
@ -139,6 +140,10 @@ interface Select {
|
|||
|
||||
const readingProgressDataSource = new ReadingProgressDataSource()
|
||||
|
||||
export const batchGetLibraryItems = async (ids: readonly string[]) => {
|
||||
return findLibraryItemsByIds(ids as string[])
|
||||
}
|
||||
|
||||
export const getItemUrl = (id: string) => `${env.client.url}/me/${id}`
|
||||
|
||||
const markItemAsRead = async (libraryItemId: string, userId: string) => {
|
||||
|
|
@ -332,6 +337,8 @@ export const buildQueryString = (
|
|||
return 'library_item.reading_progress_bottom_percent BETWEEN 2 AND 98'
|
||||
case ReadFilter.UNREAD:
|
||||
return 'library_item.reading_progress_bottom_percent < 2'
|
||||
case ReadFilter.SEEN:
|
||||
return 'library_item.seen_at IS NOT NULL'
|
||||
default:
|
||||
throw new Error(`Unexpected keyword: ${value}`)
|
||||
}
|
||||
|
|
@ -772,7 +779,7 @@ export const findRecentLibraryItems = async (
|
|||
|
||||
export const findLibraryItemsByIds = async (
|
||||
ids: string[],
|
||||
userId: string,
|
||||
userId?: string,
|
||||
options?: {
|
||||
select?: (keyof LibraryItem)[]
|
||||
}
|
||||
|
|
|
|||
50
packages/api/src/services/score.ts
Normal file
50
packages/api/src/services/score.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export interface Feature {
|
||||
library_item_id?: string
|
||||
title: string
|
||||
has_thumbnail: boolean
|
||||
has_site_icon: boolean
|
||||
saved_at: Date
|
||||
site?: string
|
||||
language?: string
|
||||
author?: string
|
||||
directionality: string
|
||||
word_count?: number
|
||||
subscription_type?: string
|
||||
folder?: string
|
||||
published_at?: Date
|
||||
subscription?: string
|
||||
}
|
||||
|
||||
export interface ScoreApiRequestBody {
|
||||
user_id: string
|
||||
items: Record<string, Feature> // item_id -> feature
|
||||
}
|
||||
|
||||
export type ScoreApiResponse = Record<string, number> // item_id -> score
|
||||
|
||||
export const getScores = async (
|
||||
data: ScoreApiRequestBody
|
||||
): Promise<ScoreApiResponse> => {
|
||||
const API_URL = 'http://digest-score/batch'
|
||||
// const token = process.env.SCORE_API_TOKEN
|
||||
|
||||
// if (!token) {
|
||||
// throw new Error('No score API token found')
|
||||
// }
|
||||
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to score candidates: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const scores = (await response.json()) as ScoreApiResponse
|
||||
return scores
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import axios from 'axios'
|
||||
import { DeepPartial, DeleteResult } from 'typeorm'
|
||||
import { DeepPartial, DeleteResult, In } from 'typeorm'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { NewsletterEmail } from '../entity/newsletter_email'
|
||||
import { Subscription } from '../entity/subscription'
|
||||
|
|
@ -214,3 +214,13 @@ export const createRssSubscriptions = async (
|
|||
) => {
|
||||
return getRepository(Subscription).save(subscriptions)
|
||||
}
|
||||
|
||||
export const findSubscriptionsByNames = async (
|
||||
userId: string,
|
||||
names: string[]
|
||||
): Promise<Subscription[]> => {
|
||||
return getRepository(Subscription).findBy([
|
||||
{ user: { id: userId }, name: In(names) },
|
||||
{ user: { id: userId }, url: In(names) },
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ import {
|
|||
REFRESH_ALL_FEEDS_JOB_NAME,
|
||||
REFRESH_FEED_JOB_NAME,
|
||||
} from '../jobs/rss/refreshAllFeeds'
|
||||
import {
|
||||
ScoreLibraryItemJobData,
|
||||
SCORE_LIBRARY_ITEM_JOB,
|
||||
} from '../jobs/score_library_item'
|
||||
import { SYNC_READ_POSITIONS_JOB_NAME } from '../jobs/sync_read_positions'
|
||||
import { TriggerRuleJobData, TRIGGER_RULE_JOB_NAME } from '../jobs/trigger_rule'
|
||||
import {
|
||||
|
|
@ -53,6 +57,7 @@ import {
|
|||
UPDATE_HIGHLIGHT_JOB,
|
||||
UPDATE_LABELS_JOB,
|
||||
} from '../jobs/update_db'
|
||||
import { UpdateHomeJobData, UPDATE_HOME_JOB } from '../jobs/update_home'
|
||||
import {
|
||||
UploadContentJobData,
|
||||
UPLOAD_CONTENT_JOB,
|
||||
|
|
@ -85,6 +90,7 @@ export const getJobPriority = (jobName: string): number => {
|
|||
case UPDATE_HIGHLIGHT_JOB:
|
||||
case SYNC_READ_POSITIONS_JOB_NAME:
|
||||
case SEND_EMAIL_JOB:
|
||||
case UPDATE_HOME_JOB:
|
||||
return 1
|
||||
case TRIGGER_RULE_JOB_NAME:
|
||||
case CALL_WEBHOOK_JOB_NAME:
|
||||
|
|
@ -95,6 +101,7 @@ export const getJobPriority = (jobName: string): number => {
|
|||
case `${REFRESH_FEED_JOB_NAME}_high`:
|
||||
case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME:
|
||||
case UPLOAD_CONTENT_JOB:
|
||||
case SCORE_LIBRARY_ITEM_JOB:
|
||||
return 10
|
||||
case `${REFRESH_FEED_JOB_NAME}_low`:
|
||||
case EXPORT_ITEM_JOB_NAME:
|
||||
|
|
@ -981,4 +988,40 @@ export const enqueueBulkUploadContentJob = async (
|
|||
return queue.addBulk(jobs)
|
||||
}
|
||||
|
||||
export const updateHomeJobId = (userId: string) =>
|
||||
`${UPDATE_HOME_JOB}_${userId}_${JOB_VERSION}`
|
||||
|
||||
export const enqueueUpdateHomeJob = async (data: UpdateHomeJobData) => {
|
||||
const queue = await getBackendQueue()
|
||||
if (!queue) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return queue.add(UPDATE_HOME_JOB, data, {
|
||||
jobId: updateHomeJobId(data.userId),
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
priority: getJobPriority(UPDATE_HOME_JOB),
|
||||
attempts: 3,
|
||||
})
|
||||
}
|
||||
|
||||
export const updateScoreJobId = (userId: string) =>
|
||||
`${SCORE_LIBRARY_ITEM_JOB}_${userId}_${JOB_VERSION}`
|
||||
|
||||
export const enqueueScoreJob = async (data: ScoreLibraryItemJobData) => {
|
||||
const queue = await getBackendQueue()
|
||||
if (!queue) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return queue.add(SCORE_LIBRARY_ITEM_JOB, data, {
|
||||
jobId: updateScoreJobId(data.userId),
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
priority: getJobPriority(SCORE_LIBRARY_ITEM_JOB),
|
||||
attempts: 3,
|
||||
})
|
||||
}
|
||||
|
||||
export default createHttpTaskWithToken
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import languages from '@cospired/i18n-iso-languages'
|
||||
import crypto from 'crypto'
|
||||
import Redis from 'ioredis'
|
||||
import normalizeUrl from 'normalize-url'
|
||||
|
|
@ -31,6 +32,7 @@ import { validateUrl } from '../services/create_page_save_request'
|
|||
import { updateLibraryItem } from '../services/library_item'
|
||||
import { Merge } from '../util'
|
||||
import { logger } from './logger'
|
||||
|
||||
interface InputObject {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any
|
||||
|
|
@ -423,3 +425,6 @@ export const getClientFromUserAgent = (userAgent: string): string => {
|
|||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export const lanaugeToCode = (language: string): string =>
|
||||
languages.getAlpha2Code(language, 'en') || 'en'
|
||||
|
|
|
|||
92
packages/db/migrations/0177.do.public_item.sql
Executable file
92
packages/db/migrations/0177.do.public_item.sql
Executable file
|
|
@ -0,0 +1,92 @@
|
|||
-- Type: DO
|
||||
-- Name: public_item
|
||||
-- Description: Create a table for public items
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE omnivore.public_item_source (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- public feeds, newsletters, or user recommended
|
||||
topics TEXT[],
|
||||
icon TEXT,
|
||||
url TEXT,
|
||||
language_codes TEXT[],
|
||||
approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TRIGGER update_public_item_source_modtime BEFORE UPDATE ON omnivore.public_item_source FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||
GRANT SELECT ON omnivore.public_item_source TO omnivore_user;
|
||||
|
||||
|
||||
CREATE TABLE omnivore.public_item (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
|
||||
source_id uuid NOT NULL REFERENCES omnivore.public_item_source(id) ON DELETE CASCADE,
|
||||
site_icon TEXT,
|
||||
type TEXT NOT NULL, -- public feeds, newsletters, or user recommended
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
topic TEXT,
|
||||
approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
thumbnail TEXT,
|
||||
preview_content TEXT,
|
||||
language_code TEXT,
|
||||
author TEXT,
|
||||
dir TEXT,
|
||||
published_at timestamptz,
|
||||
word_count INT,
|
||||
site_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TRIGGER update_public_item_modtime BEFORE UPDATE ON omnivore.public_item FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||
GRANT SELECT ON omnivore.public_item TO omnivore_user;
|
||||
|
||||
|
||||
CREATE TABLE omnivore.public_item_stats (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
|
||||
public_item_id uuid NOT NULL REFERENCES omnivore.public_item(id) ON DELETE CASCADE,
|
||||
save_count INT NOT NULL DEFAULT 0,
|
||||
like_count INT NOT NULL DEFAULT 0,
|
||||
broadcast_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX public_item_stats_public_item_id_idx ON omnivore.public_item_stats(public_item_id);
|
||||
CREATE TRIGGER update_public_item_stats_modtime BEFORE UPDATE ON omnivore.public_item_stats FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||
GRANT SELECT ON omnivore.public_item_stats TO omnivore_user;
|
||||
|
||||
|
||||
CREATE TABLE omnivore.public_item_interactions (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
|
||||
user_id uuid NOT NULL REFERENCES omnivore.user(id) ON DELETE CASCADE,
|
||||
public_item_id uuid NOT NULL REFERENCES omnivore.public_item(id) ON DELETE CASCADE,
|
||||
saved_at TIMESTAMPTZ,
|
||||
liked_at TIMESTAMPTZ,
|
||||
broadcasted_at TIMESTAMPTZ,
|
||||
seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
digested_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX public_item_interaction_user_id_idx ON omnivore.public_item_interactions(user_id);
|
||||
CREATE INDEX public_item_interaction_public_item_id_idx ON omnivore.public_item_interactions(public_item_id);
|
||||
CREATE TRIGGER update_public_item_interactions_modtime BEFORE UPDATE ON omnivore.public_item_interactions FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
|
||||
GRANT SELECT, INSERT, UPDATE ON omnivore.public_item_interactions TO omnivore_user;
|
||||
|
||||
CREATE EXTENSION LTREE;
|
||||
|
||||
ALTER TABLE omnivore.library_item
|
||||
ADD COLUMN seen_at TIMESTAMPTZ,
|
||||
ADD COLUMN digested_at TIMESTAMPTZ,
|
||||
ADD COLUMN topic LTREE,
|
||||
ADD COLUMN score FLOAT;
|
||||
|
||||
CREATE INDEX library_item_topic_idx ON omnivore.library_item USING GIST (topic);
|
||||
|
||||
COMMIT;
|
||||
22
packages/db/migrations/0177.undo.public_item.sql
Executable file
22
packages/db/migrations/0177.undo.public_item.sql
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
-- Type: UNDO
|
||||
-- Name: public_item
|
||||
-- Description: Create a table for public items
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE omnivore.public_item_interactions;
|
||||
DROP TABLE omnivore.public_item_stats;
|
||||
DROP TABLE omnivore.public_item;
|
||||
DROP TABLE omnivore.public_item_source;
|
||||
|
||||
DROP INDEX omnivore.library_item_topic_idx;
|
||||
|
||||
ALTER TABLE omnivore.library_item
|
||||
DROP COLUMN seen_at,
|
||||
DROP COLUMN digested_at,
|
||||
DROP COLUMN topic,
|
||||
DROP COLUMN score;
|
||||
|
||||
DROP EXTENSION LTREE;
|
||||
|
||||
COMMIT;
|
||||
74
yarn.lock
74
yarn.lock
|
|
@ -2442,6 +2442,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
|
||||
integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
|
||||
|
||||
"@cospired/i18n-iso-languages@^4.2.0":
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@cospired/i18n-iso-languages/-/i18n-iso-languages-4.2.0.tgz#094418a72f250fd612b3fc856b12f674a10864eb"
|
||||
integrity sha512-vy8cq1176MTxVwB1X9niQjcIYOH29F8Huxtx8hLmT5Uz3l1ztGDGri8KN/4zE7LV2mCT7JrcAoNV/I9yb+lNUw==
|
||||
|
||||
"@cspotcode/source-map-support@^0.8.0":
|
||||
version "0.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
|
||||
|
|
@ -12870,11 +12875,6 @@ cookie-signature@1.0.6:
|
|||
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
||||
integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
|
||||
|
||||
cookie@0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
|
||||
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
|
||||
|
||||
cookie@0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
|
||||
|
|
@ -13175,15 +13175,6 @@ crypto@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037"
|
||||
integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==
|
||||
|
||||
csrf@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/csrf/-/csrf-3.1.0.tgz#ec75e9656d004d674b8ef5ba47b41fbfd6cb9c30"
|
||||
integrity sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==
|
||||
dependencies:
|
||||
rndm "1.2.0"
|
||||
tsscmp "1.0.6"
|
||||
uid-safe "2.1.5"
|
||||
|
||||
css-loader@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645"
|
||||
|
|
@ -13316,16 +13307,6 @@ csstype@^3.0.2:
|
|||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340"
|
||||
integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==
|
||||
|
||||
csurf@^1.11.0:
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/csurf/-/csurf-1.11.0.tgz#ab0c3c6634634192bd3d6f4b861be20800eeb61a"
|
||||
integrity sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==
|
||||
dependencies:
|
||||
cookie "0.4.0"
|
||||
cookie-signature "1.0.6"
|
||||
csrf "3.1.0"
|
||||
http-errors "~1.7.3"
|
||||
|
||||
csv-file-validator@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/csv-file-validator/-/csv-file-validator-2.1.0.tgz#fc83e1e05835d7f03d03f8cce6235938e4cef32e"
|
||||
|
|
@ -17951,17 +17932,6 @@ http-errors@~1.6.2:
|
|||
setprototypeof "1.1.0"
|
||||
statuses ">= 1.4.0 < 2"
|
||||
|
||||
http-errors@~1.7.3:
|
||||
version "1.7.3"
|
||||
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
|
||||
integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
|
||||
dependencies:
|
||||
depd "~1.1.2"
|
||||
inherits "2.0.4"
|
||||
setprototypeof "1.1.1"
|
||||
statuses ">= 1.5.0 < 2"
|
||||
toidentifier "1.0.0"
|
||||
|
||||
http-parser-js@>=0.5.1:
|
||||
version "0.5.5"
|
||||
resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.5.tgz#d7c30d5d3c90d865b4a2e870181f9d6f22ac7ac5"
|
||||
|
|
@ -26218,11 +26188,6 @@ randexp@0.4.6:
|
|||
discontinuous-range "1.0.0"
|
||||
ret "~0.1.10"
|
||||
|
||||
random-bytes@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b"
|
||||
integrity sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==
|
||||
|
||||
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
|
||||
|
|
@ -27837,11 +27802,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
|
|||
hash-base "^3.0.0"
|
||||
inherits "^2.0.1"
|
||||
|
||||
rndm@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c"
|
||||
integrity sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==
|
||||
|
||||
rollup@2.78.0:
|
||||
version "2.78.0"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.0.tgz#00995deae70c0f712ea79ad904d5f6b033209d9e"
|
||||
|
|
@ -28341,11 +28301,6 @@ setprototypeof@1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
|
||||
integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
|
||||
|
||||
setprototypeof@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
|
||||
integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
|
||||
|
|
@ -28997,7 +28952,7 @@ statuses@2.0.1:
|
|||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
|
||||
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
|
||||
|
||||
"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2":
|
||||
"statuses@>= 1.4.0 < 2":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
|
||||
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
|
||||
|
|
@ -30075,11 +30030,6 @@ toggle-selection@^1.0.6:
|
|||
resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
|
||||
integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI=
|
||||
|
||||
toidentifier@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
|
||||
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
|
||||
|
||||
toidentifier@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
|
||||
|
|
@ -30371,11 +30321,6 @@ tslib@~2.4.0:
|
|||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
|
||||
integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
|
||||
|
||||
tsscmp@1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
|
||||
integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
|
||||
|
||||
tsutils@^3.21.0:
|
||||
version "3.21.0"
|
||||
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
|
||||
|
|
@ -30640,13 +30585,6 @@ uhyphen@^0.2.0:
|
|||
resolved "https://registry.yarnpkg.com/uhyphen/-/uhyphen-0.2.0.tgz#8fdf0623314486e020a3c00ee5cc7a12fe722b81"
|
||||
integrity sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==
|
||||
|
||||
uid-safe@2.1.5:
|
||||
version "2.1.5"
|
||||
resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a"
|
||||
integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==
|
||||
dependencies:
|
||||
random-bytes "~1.0.0"
|
||||
|
||||
unbox-primitive@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
|
||||
|
|
|
|||
Loading…
Reference in a new issue