Merge pull request #3982 from omnivore-app/feature/just-read

feat: just read api
This commit is contained in:
Hongbo Wu 2024-05-28 15:54:24 +08:00 committed by GitHub
commit b3578d539b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1618 additions and 95 deletions

View file

@ -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",

View file

@ -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[])
}),
},
}

View file

@ -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
}

View 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
}

View 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
}

View 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
}

View 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
}

View file

@ -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>;

View file

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

View 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)
}

View 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 })
}

View file

@ -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>(

View file

@ -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}`)
}

View file

@ -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'),
}

View 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
},
}
})

View file

@ -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>
}
}

View file

@ -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
}
`

View 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
)
}

View file

@ -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)[]
}

View 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
}

View file

@ -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) },
])
}

View file

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

View file

@ -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'

View 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;

View 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;

View file

@ -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"