Merge pull request #4031 from omnivore-app/feature/highlights-api

feat: highlights api
This commit is contained in:
Hongbo Wu 2024-06-06 17:46:29 +08:00 committed by GitHub
commit 8f5bbdaeb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 758 additions and 613 deletions

View file

@ -33,7 +33,10 @@ import ScalarResolvers from './scalars'
import typeDefs from './schema'
import { batchGetHighlightsFromLibraryItemIds } from './services/highlights'
import { batchGetPublicItems } from './services/home'
import { batchGetLabelsFromLibraryItemIds } from './services/labels'
import {
batchGetLabelsFromHighlightIds,
batchGetLabelsFromLibraryItemIds,
} from './services/labels'
import { batchGetLibraryItems } from './services/library_item'
import { batchGetRecommendationsFromLibraryItemIds } from './services/recommendation'
import {
@ -42,6 +45,7 @@ import {
} from './services/service_usage'
import { batchGetSubscriptionsByNames } from './services/subscriptions'
import { batchGetUploadFilesByIds } from './services/upload_file'
import { findUsersByIds } from './services/user'
import { tracer } from './tracing'
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
import { SetClaimsRole } from './utils/dictionary'
@ -124,6 +128,10 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
return batchGetSubscriptionsByNames(claims.uid, names as string[])
}),
users: new DataLoader(async (ids: readonly string[]) =>
findUsersByIds(ids as string[])
),
highlightLabels: new DataLoader(batchGetLabelsFromHighlightIds),
},
}

View file

@ -36,6 +36,9 @@ export class Highlight {
@JoinColumn({ name: 'user_id' })
user!: User
@Column('uuid')
userId!: string
@ManyToOne(() => LibraryItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'library_item_id' })
libraryItem!: LibraryItem

View file

@ -46,6 +46,9 @@ export class LibraryItem {
@JoinColumn({ name: 'user_id' })
user!: User
@Column('uuid')
userId!: string
@Column('enum', {
enum: LibraryItemState,
default: LibraryItemState.Succeeded,

View file

@ -1261,6 +1261,7 @@ export type Highlight = {
html?: Maybe<Scalars['String']>;
id: Scalars['ID'];
labels?: Maybe<Array<Label>>;
libraryItem: Article;
patch?: Maybe<Scalars['String']>;
prefix?: Maybe<Scalars['String']>;
quote?: Maybe<Scalars['String']>;
@ -1275,6 +1276,12 @@ export type Highlight = {
user: User;
};
export type HighlightEdge = {
__typename?: 'HighlightEdge';
cursor: Scalars['String'];
node: Highlight;
};
export type HighlightReply = {
__typename?: 'HighlightReply';
createdAt: Scalars['Date'];
@ -1296,6 +1303,23 @@ export enum HighlightType {
Redaction = 'REDACTION'
}
export type HighlightsError = {
__typename?: 'HighlightsError';
errorCodes: Array<HighlightsErrorCode>;
};
export enum HighlightsErrorCode {
BadRequest = 'BAD_REQUEST'
}
export type HighlightsResult = HighlightsError | HighlightsSuccess;
export type HighlightsSuccess = {
__typename?: 'HighlightsSuccess';
edges: Array<HighlightEdge>;
pageInfo: PageInfo;
};
export type HomeEdge = {
__typename?: 'HomeEdge';
cursor: Scalars['String'];
@ -2261,6 +2285,7 @@ export type Query = {
groups: GroupsResult;
hello?: Maybe<Scalars['String']>;
hiddenHomeSection: HiddenHomeSectionResult;
highlights: HighlightsResult;
home: HomeResult;
integration: IntegrationResult;
integrations: IntegrationsResult;
@ -2311,6 +2336,13 @@ export type QueryGetDiscoverFeedArticlesArgs = {
};
export type QueryHighlightsArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
query?: InputMaybe<Scalars['String']>;
};
export type QueryHomeArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
@ -4331,9 +4363,14 @@ export type ResolversTypes = {
HiddenHomeSectionResult: ResolversTypes['HiddenHomeSectionError'] | ResolversTypes['HiddenHomeSectionSuccess'];
HiddenHomeSectionSuccess: ResolverTypeWrapper<HiddenHomeSectionSuccess>;
Highlight: ResolverTypeWrapper<Highlight>;
HighlightEdge: ResolverTypeWrapper<HighlightEdge>;
HighlightReply: ResolverTypeWrapper<HighlightReply>;
HighlightStats: ResolverTypeWrapper<HighlightStats>;
HighlightType: HighlightType;
HighlightsError: ResolverTypeWrapper<HighlightsError>;
HighlightsErrorCode: HighlightsErrorCode;
HighlightsResult: ResolversTypes['HighlightsError'] | ResolversTypes['HighlightsSuccess'];
HighlightsSuccess: ResolverTypeWrapper<HighlightsSuccess>;
HomeEdge: ResolverTypeWrapper<HomeEdge>;
HomeError: ResolverTypeWrapper<HomeError>;
HomeErrorCode: HomeErrorCode;
@ -4900,8 +4937,12 @@ export type ResolversParentTypes = {
HiddenHomeSectionResult: ResolversParentTypes['HiddenHomeSectionError'] | ResolversParentTypes['HiddenHomeSectionSuccess'];
HiddenHomeSectionSuccess: HiddenHomeSectionSuccess;
Highlight: Highlight;
HighlightEdge: HighlightEdge;
HighlightReply: HighlightReply;
HighlightStats: HighlightStats;
HighlightsError: HighlightsError;
HighlightsResult: ResolversParentTypes['HighlightsError'] | ResolversParentTypes['HighlightsSuccess'];
HighlightsSuccess: HighlightsSuccess;
HomeEdge: HomeEdge;
HomeError: HomeError;
HomeItem: HomeItem;
@ -6088,6 +6129,7 @@ export type HighlightResolvers<ContextType = ResolverContext, ParentType extends
html?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
labels?: Resolver<Maybe<Array<ResolversTypes['Label']>>, ParentType, ContextType>;
libraryItem?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
patch?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
prefix?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
quote?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -6103,6 +6145,12 @@ export type HighlightResolvers<ContextType = ResolverContext, ParentType extends
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightEdgeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightEdge'] = ResolversParentTypes['HighlightEdge']> = {
cursor?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
node?: Resolver<ResolversTypes['Highlight'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightReplyResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightReply'] = ResolversParentTypes['HighlightReply']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
highlight?: Resolver<ResolversTypes['Highlight'], ParentType, ContextType>;
@ -6118,6 +6166,21 @@ export type HighlightStatsResolvers<ContextType = ResolverContext, ParentType ex
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightsError'] = ResolversParentTypes['HighlightsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['HighlightsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type HighlightsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightsResult'] = ResolversParentTypes['HighlightsResult']> = {
__resolveType: TypeResolveFn<'HighlightsError' | 'HighlightsSuccess', ParentType, ContextType>;
};
export type HighlightsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['HighlightsSuccess'] = ResolversParentTypes['HighlightsSuccess']> = {
edges?: Resolver<Array<ResolversTypes['HighlightEdge']>, ParentType, ContextType>;
pageInfo?: Resolver<ResolversTypes['PageInfo'], ParentType, ContextType>;
__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>;
@ -6581,6 +6644,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
hiddenHomeSection?: Resolver<ResolversTypes['HiddenHomeSectionResult'], ParentType, ContextType>;
highlights?: Resolver<ResolversTypes['HighlightsResult'], ParentType, ContextType, Partial<QueryHighlightsArgs>>;
home?: Resolver<ResolversTypes['HomeResult'], ParentType, ContextType, Partial<QueryHomeArgs>>;
integration?: Resolver<ResolversTypes['IntegrationResult'], ParentType, ContextType, RequireFields<QueryIntegrationArgs, 'name'>>;
integrations?: Resolver<ResolversTypes['IntegrationsResult'], ParentType, ContextType>;
@ -7797,8 +7861,12 @@ export type Resolvers<ContextType = ResolverContext> = {
HiddenHomeSectionResult?: HiddenHomeSectionResultResolvers<ContextType>;
HiddenHomeSectionSuccess?: HiddenHomeSectionSuccessResolvers<ContextType>;
Highlight?: HighlightResolvers<ContextType>;
HighlightEdge?: HighlightEdgeResolvers<ContextType>;
HighlightReply?: HighlightReplyResolvers<ContextType>;
HighlightStats?: HighlightStatsResolvers<ContextType>;
HighlightsError?: HighlightsErrorResolvers<ContextType>;
HighlightsResult?: HighlightsResultResolvers<ContextType>;
HighlightsSuccess?: HighlightsSuccessResolvers<ContextType>;
HomeEdge?: HomeEdgeResolvers<ContextType>;
HomeError?: HomeErrorResolvers<ContextType>;
HomeItem?: HomeItemResolvers<ContextType>;

View file

@ -1133,6 +1133,7 @@ type Highlight {
html: String
id: ID!
labels: [Label!]
libraryItem: Article!
patch: String
prefix: String
quote: String
@ -1147,6 +1148,11 @@ type Highlight {
user: User!
}
type HighlightEdge {
cursor: String!
node: Highlight!
}
type HighlightReply {
createdAt: Date!
highlight: Highlight!
@ -1166,6 +1172,21 @@ enum HighlightType {
REDACTION
}
type HighlightsError {
errorCodes: [HighlightsErrorCode!]!
}
enum HighlightsErrorCode {
BAD_REQUEST
}
union HighlightsResult = HighlightsError | HighlightsSuccess
type HighlightsSuccess {
edges: [HighlightEdge!]!
pageInfo: PageInfo!
}
type HomeEdge {
cursor: String!
node: HomeSection!
@ -1738,6 +1759,7 @@ type Query {
groups: GroupsResult!
hello: String
hiddenHomeSection: HiddenHomeSectionResult!
highlights(after: String, first: Int, query: String): HighlightsResult!
home(after: String, first: Int): HomeResult!
integration(name: String!): IntegrationResult!
integrations: IntegrationsResult!

View file

@ -12,6 +12,26 @@ import { appDataSource } from '../data_source'
import { Claims } from '../resolvers/types'
import { SetClaimsRole } from '../utils/dictionary'
export enum SortOrder {
ASCENDING = 'ASC',
DESCENDING = 'DESC',
}
export interface Sort {
by: string
order?: SortOrder
nulls?: 'NULLS FIRST' | 'NULLS LAST'
}
export interface Select {
column: string
alias?: string
}
export const paramtersToObject = (parameters: ObjectLiteral[]) => {
return parameters.reduce((a, b) => ({ ...a, ...b }), {})
}
export const getColumns = <T extends ObjectLiteral>(
repository: Repository<T>
): (keyof T)[] => {

View file

@ -5,7 +5,12 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { Readability } from '@omnivore/readability'
import graphqlFields from 'graphql-fields'
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import {
ContentReaderType,
LibraryItem,
LibraryItemState,
} from '../../entity/library_item'
import { User } from '../../entity/user'
import { env } from '../../env'
import {
ArticleError,
@ -43,6 +48,7 @@ import {
SaveArticleReadingProgressSuccess,
SearchError,
SearchErrorCode,
SearchItemEdge,
SearchSuccess,
SetBookmarkArticleError,
SetBookmarkArticleErrorCode,
@ -87,6 +93,7 @@ import {
setFileUploadComplete,
} from '../../services/upload_file'
import { traceAs } from '../../tracing'
import { Merge } from '../../util'
import { analytics } from '../../utils/analytics'
import { isSiteBlockedForParse } from '../../utils/blocked'
import { enqueueBulkAction } from '../../utils/createTask'
@ -96,10 +103,7 @@ import {
errorHandler,
generateSlug,
isParsingTimeout,
libraryItemToArticle,
libraryItemToSearchItem,
titleForFilePath,
userDataToUser,
} from '../../utils/helpers'
import {
getDistillerResult,
@ -126,7 +130,10 @@ const FORCE_PUPPETEER_URLS = [
const UNPARSEABLE_CONTENT = '<p>We were unable to parse this page.</p>'
export const createArticleResolver = authorized<
CreateArticleSuccess,
Merge<
CreateArticleSuccess,
{ user: User; createdArticle: Partial<LibraryItem> }
>,
CreateArticleError,
MutationCreateArticleArgs
>(
@ -160,8 +167,8 @@ export const createArticleResolver = authorized<
},
})
const userData = await userRepository.findById(uid)
if (!userData) {
const user = await userRepository.findById(uid)
if (!user) {
return errorHandler(
{
errorCodes: [CreateArticleErrorCode.Unauthorized],
@ -171,7 +178,6 @@ export const createArticleResolver = authorized<
pubsub
)
}
const user = userDataToUser(userData)
try {
if (isSiteBlockedForParse(url)) {
@ -203,25 +209,22 @@ export const createArticleResolver = authorized<
let domContent = null
let itemType = PageType.Unknown
const DUMMY_RESPONSE: CreateArticleSuccess = {
const DUMMY_RESPONSE = {
user,
created: false,
createdArticle: {
id: '',
slug: '',
createdAt: new Date(),
originalHtml: domContent,
content: '',
originalContent: domContent,
readableContent: '',
description: '',
title: '',
pageType: itemType,
contentReader: ContentReader.Web,
itemType,
contentReader: ContentReaderType.WEB,
author: '',
url,
hash: '',
isArchived: false,
readingProgressAnchorIndex: 0,
readingProgressPercent: 0,
originalUrl: url,
textContentHash: '',
highlights: [],
savedAt: savedAt || new Date(),
updatedAt: new Date(),
@ -257,7 +260,7 @@ export const createArticleResolver = authorized<
FORCE_PUPPETEER_URLS.some((regex) => regex.test(url))
) {
await createPageSaveRequest({
user: userData,
user: user,
url,
state: state || undefined,
labels: inputLabels || undefined,
@ -282,7 +285,7 @@ export const createArticleResolver = authorized<
// We have a URL but no document, so we try to send this to puppeteer
// and return a dummy response.
await createPageSaveRequest({
user: userData,
user,
url,
state: state || undefined,
labels: inputLabels || undefined,
@ -353,7 +356,7 @@ export const createArticleResolver = authorized<
return {
user,
created: true,
createdArticle: libraryItemToArticle(libraryItemToReturn),
createdArticle: libraryItemToReturn,
}
} catch (error) {
log.error('Error creating article', error)
@ -370,7 +373,7 @@ export const createArticleResolver = authorized<
)
export const getArticleResolver = authorized<
ArticleSuccess,
Merge<ArticleSuccess, { article: LibraryItem }>,
ArticleError,
QueryArticleArgs
>(async (_obj, { slug, format }, { authTrx, uid, log }, info) => {
@ -439,7 +442,7 @@ export const getArticleResolver = authorized<
}
return {
article: libraryItemToArticle(libraryItem),
article: libraryItem,
}
} catch (error) {
log.error(error)
@ -447,88 +450,8 @@ export const getArticleResolver = authorized<
}
})
// type PaginatedPartialArticles = {
// edges: { cursor: string; node: PartialArticle }[]
// pageInfo: PageInfo
// }
// export type SetShareArticleSuccessPartial = Merge<
// SetShareArticleSuccess,
// {
// updatedFeedArticle?: Omit<
// FeedArticle,
// | 'sharedBy'
// | 'article'
// | 'highlightsCount'
// | 'annotationsCount'
// | 'reactions'
// >
// updatedFeedArticleId?: string
// updatedArticle: PartialArticle
// }
// >
// export const setShareArticleResolver = authorized<
// SetShareArticleSuccessPartial,
// SetShareArticleError,
// MutationSetShareArticleArgs
// >(
// async (
// _,
// { input: { articleID, share, sharedComment, sharedWithHighlights } },
// { models, authTrx, claims: { uid }, log }
// ) => {
// const article = await models.article.get(articleID)
// if (!article) {
// return { errorCodes: [SetShareArticleErrorCode.NotFound] }
// }
// const sharedAt = share ? new Date() : null
// log.info(`${share ? 'S' : 'Uns'}haring an article`, {
// article: Object.assign({}, article, {
// content: undefined,
// originalHtml: undefined,
// sharedAt,
// }),
// labels: {
// source: 'resolver',
// resolver: 'setShareArticleResolver',
// articleId: article.id,
// distinctId: uid,
// },
// })
// const result = await authTrx((tx) =>
// models.userArticle.updateByArticleId(
// uid,
// articleID,
// { sharedAt, sharedComment, sharedWithHighlights },
// tx
// )
// )
// if (!result) {
// return { errorCodes: [SetShareArticleErrorCode.NotFound] }
// }
// // Make sure article.id instead of userArticle.id has passed. We use it for cache updates
// const updatedArticle = {
// ...result,
// ...article,
// postedByViewer: !!sharedAt,
// }
// const updatedFeedArticle = sharedAt ? { ...result, sharedAt } : undefined
// return {
// updatedFeedArticleId: result.id,
// updatedFeedArticle,
// updatedArticle,
// }
// }
// )
export const setBookmarkArticleResolver = authorized<
SetBookmarkArticleSuccess,
Merge<SetBookmarkArticleSuccess, { bookmarkedArticle: LibraryItem }>,
SetBookmarkArticleError,
MutationSetBookmarkArticleArgs
>(async (_, { input: { articleID } }, { uid, log, pubsub }) => {
@ -556,12 +479,12 @@ export const setBookmarkArticleResolver = authorized<
})
// Make sure article.id instead of userArticle.id has passed. We use it for cache updates
return {
bookmarkedArticle: libraryItemToArticle(deletedLibraryItem),
bookmarkedArticle: deletedLibraryItem,
}
})
export const saveArticleReadingProgressResolver = authorized<
SaveArticleReadingProgressSuccess,
Merge<SaveArticleReadingProgressSuccess, { updatedArticle: LibraryItem }>,
SaveArticleReadingProgressError,
MutationSaveArticleReadingProgressArgs
>(
@ -661,13 +584,15 @@ export const saveArticleReadingProgressResolver = authorized<
}
return {
updatedArticle: libraryItemToArticle(updatedItem),
updatedArticle: updatedItem,
}
}
)
export type PartialLibraryItem = Merge<LibraryItem, { format?: string }>
type PartialSearchItemEdge = Merge<SearchItemEdge, { node: PartialLibraryItem }>
export const searchResolver = authorized<
SearchSuccess,
Merge<SearchSuccess, { edges: Array<PartialSearchItemEdge> }>,
SearchError,
QuerySearchArgs
>(async (_obj, params, { uid }) => {
@ -704,7 +629,10 @@ export const searchResolver = authorized<
return {
edges: libraryItems.map((item) => ({
node: libraryItemToSearchItem(item, params.format as ArticleFormat),
node: {
...item,
format: params.format || undefined,
},
cursor: endCursor,
})),
pageInfo: {
@ -738,7 +666,7 @@ export const typeaheadSearchResolver = authorized<
})
export const updatesSinceResolver = authorized<
UpdatesSinceSuccess,
Merge<UpdatesSinceSuccess, { edges: Array<PartialSearchItemEdge> }>,
UpdatesSinceError,
QueryUpdatesSinceArgs
>(async (_obj, { since, first, after, sort: sortParams, folder }, { uid }) => {
@ -781,7 +709,7 @@ export const updatesSinceResolver = authorized<
const edges = libraryItems.map((item) => {
const updateReason = getUpdateReason(item, startDate)
return {
node: libraryItemToSearchItem(item),
node: item,
cursor: endCursor,
itemID: item.id,
updateReason,

View file

@ -17,17 +17,17 @@ import {
findLibraryItemById,
findLibraryItemByUrl,
} from '../../services/library_item'
import { Merge } from '../../util'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/gql-utils'
import {
cleanUrl,
isParsingTimeout,
libraryItemToArticleSavingRequest,
} from '../../utils/helpers'
import { cleanUrl, isParsingTimeout } from '../../utils/helpers'
import { isErrorWithCode } from '../user'
export const createArticleSavingRequestResolver = authorized<
CreateArticleSavingRequestSuccess,
Merge<
CreateArticleSavingRequestSuccess,
{ articleSavingRequest: LibraryItem }
>,
CreateArticleSavingRequestError,
MutationCreateArticleSavingRequestArgs
>(async (_, { input: { url } }, { uid, pubsub, log }) => {
@ -67,7 +67,7 @@ export const createArticleSavingRequestResolver = authorized<
})
export const articleSavingRequestResolver = authorized<
ArticleSavingRequestSuccess,
Merge<ArticleSavingRequestSuccess, { articleSavingRequest: LibraryItem }>,
ArticleSavingRequestError,
QueryArticleSavingRequestArgs
>(async (_, { id, url }, { uid, log }) => {
@ -109,10 +109,7 @@ export const articleSavingRequestResolver = authorized<
libraryItem.state = LibraryItemState.Succeeded
}
return {
articleSavingRequest: libraryItemToArticleSavingRequest(
user,
libraryItem
),
articleSavingRequest: libraryItem,
}
} catch (error) {
log.error('articleSavingRequestResolver error', error)

View file

@ -5,40 +5,31 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { createHmac } from 'crypto'
import { isError } from 'lodash'
import { Highlight as HighlightEntity } from '../entity/highlight'
import { Highlight } 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 { Recommendation } from '../entity/recommendation'
import {
DEFAULT_SUBSCRIPTION_FOLDER,
Subscription,
} from '../entity/subscription'
import { User as UserEntity } from '../entity/user'
import { env } from '../env'
import {
Article,
Highlight,
HomeItem,
HomeItemSource,
HomeItemSourceType,
Label,
PageType,
Recommendation,
SearchItem,
User,
} from '../generated/graphql'
import { getAISummary } from '../services/ai-summaries'
import { findUserFeatures } from '../services/features'
import { Merge } from '../util'
import {
highlightDataToHighlight,
isBase64Image,
recommandationDataToRecommendation,
validatedDate,
wordsCount,
} from '../utils/helpers'
import { isBase64Image, validatedDate, wordsCount } from '../utils/helpers'
import { createImageProxyUrl } from '../utils/imageproxy'
import { contentConverter } from '../utils/parser'
import {
@ -49,6 +40,7 @@ import {
ArticleFormat,
emptyTrashResolver,
fetchContentResolver,
PartialLibraryItem,
} from './article'
import {
addDiscoverFeedResolver,
@ -60,6 +52,7 @@ import {
saveDiscoverArticleResolver,
} from './discover_feeds'
import { optInFeatureResolver } from './features'
import { highlightsResolver } from './highlight'
import {
hiddenHomeSectionResolver,
homeResolver,
@ -77,14 +70,12 @@ import {
createHighlightResolver,
createLabelResolver,
createNewsletterEmailResolver,
// createReminderResolver,
deleteAccountResolver,
deleteFilterResolver,
deleteHighlightResolver,
deleteIntegrationResolver,
deleteLabelResolver,
deleteNewsletterEmailResolver,
// deleteReminderResolver,
deleteRuleResolver,
deleteWebhookResolver,
deviceTokensResolver,
@ -94,11 +85,7 @@ import {
generateApiKeyResolver,
getAllUsersResolver,
getArticleResolver,
// getFollowersResolver,
// getFollowingResolver,
getMeUserResolver,
// getSharedArticleResolver,
// getUserFeedArticlesResolver,
getUserPersonalizationResolver,
getUserResolver,
googleLoginResolver,
@ -118,7 +105,6 @@ import {
newsletterEmailsResolver,
recommendHighlightsResolver,
recommendResolver,
// reminderResolver,
reportItemResolver,
revokeApiKeyResolver,
rulesResolver,
@ -133,14 +119,11 @@ import {
setBookmarkArticleResolver,
setDeviceTokenResolver,
setFavoriteArticleResolver,
// setFollowResolver,
setIntegrationResolver,
setLabelsForHighlightResolver,
setLabelsResolver,
setLinkArchivedResolver,
setRuleResolver,
// setShareArticleResolver,
// setShareHighlightResolver,
setUserPersonalizationResolver,
setWebhookResolver,
subscribeResolver,
@ -151,10 +134,7 @@ import {
updateHighlightResolver,
updateLabelResolver,
updateNewsletterEmailResolver,
// updateLinkShareInfoResolver,
updatePageResolver,
// updateReminderResolver,
// updateSharedCommentResolver,
updatesSinceResolver,
updateSubscriptionResolver,
updateUserProfileResolver,
@ -192,7 +172,7 @@ const resultResolveTypeResolver = (
const readingProgressHandlers = {
async readingProgressPercent(
article: { id: string; readingProgressPercent?: number },
article: LibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
@ -204,15 +184,15 @@ const readingProgressHandlers = {
)
if (readingProgress) {
return Math.max(
article.readingProgressPercent ?? 0,
article.readingProgressBottomPercent ?? 0,
readingProgress.readingProgressPercent
)
}
}
return article.readingProgressPercent
return article.readingProgressBottomPercent
},
async readingProgressAnchorIndex(
article: { id: string; readingProgressAnchorIndex?: number },
article: LibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
@ -224,15 +204,15 @@ const readingProgressHandlers = {
)
if (readingProgress && readingProgress.readingProgressAnchorIndex) {
return Math.max(
article.readingProgressAnchorIndex ?? 0,
article.readingProgressHighestReadAnchor ?? 0,
readingProgress.readingProgressAnchorIndex
)
}
}
return article.readingProgressAnchorIndex
return article.readingProgressHighestReadAnchor
},
async readingProgressTopPercent(
article: { id: string; readingProgressTopPercent?: number },
article: LibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
@ -265,30 +245,20 @@ export const functionResolvers = {
updateUserProfile: updateUserProfileResolver,
createArticle: createArticleResolver,
createHighlight: createHighlightResolver,
// createReaction: createReactionResolver,
// deleteReaction: deleteReactionResolver,
mergeHighlight: mergeHighlightResolver,
updateHighlight: updateHighlightResolver,
deleteHighlight: deleteHighlightResolver,
uploadFileRequest: uploadFileRequestResolver,
// setShareArticle: setShareArticleResolver,
// updateSharedComment: updateSharedCommentResolver,
// setFollow: setFollowResolver,
setBookmarkArticle: setBookmarkArticleResolver,
setUserPersonalization: setUserPersonalizationResolver,
createArticleSavingRequest: createArticleSavingRequestResolver,
// setShareHighlight: setShareHighlightResolver,
reportItem: reportItemResolver,
// updateLinkShareInfo: updateLinkShareInfoResolver,
setLinkArchived: setLinkArchivedResolver,
createNewsletterEmail: createNewsletterEmailResolver,
deleteNewsletterEmail: deleteNewsletterEmailResolver,
saveUrl: saveUrlResolver,
savePage: savePageResolver,
saveFile: saveFileResolver,
// createReminder: createReminderResolver,
// updateReminder: updateReminderResolver,
// deleteReminder: deleteReminderResolver,
setDeviceToken: setDeviceTokenResolver,
createLabel: createLabelResolver,
updateLabel: updateLabelResolver,
@ -346,14 +316,9 @@ export const functionResolvers = {
users: getAllUsersResolver,
validateUsername: validateUsernameResolver,
article: getArticleResolver,
// sharedArticle: getSharedArticleResolver,
// feedArticles: getUserFeedArticlesResolver,
// getFollowers: getFollowersResolver,
// getFollowing: getFollowingResolver,
getUserPersonalization: getUserPersonalizationResolver,
articleSavingRequest: articleSavingRequestResolver,
newsletterEmails: newsletterEmailsResolver,
// reminder: reminderResolver,
labels: labelsResolver,
search: searchResolver,
subscriptions: subscriptionsResolver,
@ -376,13 +341,10 @@ export const functionResolvers = {
home: homeResolver,
subscription: subscriptionResolver,
hiddenHomeSection: hiddenHomeSectionResolver,
highlights: highlightsResolver,
},
User: {
async intercomHash(
user: User,
__: Record<string, unknown>,
ctx: WithDataSourcesContext
) {
async intercomHash(user: User) {
if (env.intercom.secretKey) {
const userIdentifier = user.id.toString()
@ -414,12 +376,22 @@ export const functionResolvers = {
return findUserFeatures(ctx.claims.uid)
},
picture: (user: UserEntity) => user.profile.pictureUrl,
// not implemented yet
friendsCount: () => 0,
followersCount: () => 0,
isFullUser: () => true,
viewerIsFollowing: () => false,
sharedArticles: () => [],
sharedArticlesCount: () => 0,
sharedHighlightsCount: () => 0,
sharedNotesCount: () => 0,
},
Article: {
async url(article: Article, _: unknown, ctx: WithDataSourcesContext) {
async url(article: LibraryItem, _: unknown, ctx: WithDataSourcesContext) {
if (
(article.pageType == PageType.File ||
article.pageType == PageType.Book) &&
(article.itemType == PageType.File ||
article.itemType == PageType.Book) &&
ctx.claims &&
article.uploadFileId
) {
@ -432,29 +404,33 @@ export const functionResolvers = {
const filePath = generateUploadFilePathName(upload.id, upload.fileName)
return generateDownloadSignedUrl(filePath)
}
return article.url
return article.originalUrl
},
originalArticleUrl(article: { url: string }) {
return article.url
originalArticleUrl(article: LibraryItem) {
return article.originalUrl
},
hasContent(article: {
content: string | null
originalHtml: string | null
}) {
return !!article.originalHtml && !!article.content
hasContent(article: LibraryItem) {
return !!article.originalContent && !!article.readableContent
},
publishedAt(article: { publishedAt: Date }) {
return validatedDate(article.publishedAt)
publishedAt(article: LibraryItem) {
return validatedDate(article.publishedAt || undefined)
},
image(article: { image?: string }): string | undefined {
return article.image && createImageProxyUrl(article.image, 320, 320)
image(article: LibraryItem): string | undefined {
if (article.thumbnail) {
return createImageProxyUrl(article.thumbnail, 320, 320)
}
return undefined
},
wordsCount(article: { wordCount?: number; content?: string }) {
wordsCount(article: LibraryItem): number | undefined {
if (article.wordCount) return article.wordCount
return article.content ? wordsCount(article.content) : undefined
return article.readableContent
? wordsCount(article.readableContent)
: undefined
},
async labels(
article: { id: string; labels?: Label[] },
article: LibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
@ -462,40 +438,57 @@ export const functionResolvers = {
return ctx.dataLoaders.labels.load(article.id)
},
async highlights(
article: LibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
if (article.highlights) return article.highlights
return ctx.dataLoaders.highlights.load(article.id)
},
content: (item: LibraryItem) => item.readableContent,
hash: (item: LibraryItem) => item.textContentHash || '',
isArchived: (item: LibraryItem) => !!item.archivedAt,
uploadFileId: (item: LibraryItem) => item.uploadFile?.id,
pageType: (item: LibraryItem) => item.itemType,
...readingProgressHandlers,
},
Highlight: {
// async reactions(
// highlight: { id: string; reactions?: Reaction[] },
// _: unknown,
// ctx: WithDataSourcesContext
// ) {
// const { reactions, id } = highlight
// if (reactions) return reactions
// return await ctx.models.reaction.batchGetFromHighlight(id)
// },
reactions: () => [],
replies: () => [],
type: (highlight: Highlight) => highlight.highlightType,
async user(highlight: Highlight, __: unknown, ctx: WithDataSourcesContext) {
return ctx.dataLoaders.users.load(highlight.userId)
},
createdByMe(
highlight: { user: { id: string } },
highlight: Highlight,
__: unknown,
ctx: WithDataSourcesContext
) {
return highlight.user.id === ctx.uid
return highlight.userId === ctx.uid
},
libraryItem(highlight: Highlight, _: unknown, ctx: WithDataSourcesContext) {
if (highlight.libraryItem) {
return highlight.libraryItem
}
return ctx.dataLoaders.libraryItems.load(highlight.libraryItemId)
},
labels: async (
highlight: Highlight,
_: unknown,
ctx: WithDataSourcesContext
) => {
return (
highlight.labels || ctx.dataLoaders.highlightLabels.load(highlight.id)
)
},
},
// Reaction: {
// async user(
// reaction: { userId: string },
// __: unknown,
// ctx: WithDataSourcesContext
// ) {
// return userDataToUser(await ctx.models.user.get(reaction.userId))
// },
// },
SearchItem: {
async url(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) {
async url(item: LibraryItem, _: unknown, ctx: WithDataSourcesContext) {
if (
(item.pageType == PageType.File || item.pageType == PageType.Book) &&
(item.itemType == PageType.File || item.itemType == PageType.Book) &&
ctx.claims &&
item.uploadFileId
) {
@ -506,51 +499,44 @@ export const functionResolvers = {
const filePath = generateUploadFilePathName(upload.id, upload.fileName)
return generateDownloadSignedUrl(filePath)
}
return item.url
return item.originalUrl
},
image(item: SearchItem) {
return item.image && createImageProxyUrl(item.image, 320, 320)
image(item: LibraryItem) {
return item.thumbnail && createImageProxyUrl(item.thumbnail, 320, 320)
},
originalArticleUrl(item: { url: string }) {
return item.url
originalArticleUrl(item: LibraryItem) {
return item.originalUrl
},
wordsCount(item: { wordCount?: number; content?: string }) {
wordsCount(item: LibraryItem) {
if (item.wordCount) return item.wordCount
return item.content ? wordsCount(item.content) : undefined
return item.readableContent ? wordsCount(item.readableContent) : undefined
},
siteIcon(item: { siteIcon?: string }) {
siteIcon(item: LibraryItem) {
if (item.siteIcon && !isBase64Image(item.siteIcon)) {
return createImageProxyUrl(item.siteIcon, 128, 128)
}
return item.siteIcon
},
async labels(
item: { id: string; labels?: Label[] },
_: unknown,
ctx: WithDataSourcesContext
) {
async labels(item: LibraryItem, _: unknown, ctx: WithDataSourcesContext) {
if (item.labels) return item.labels
const labels = await ctx.dataLoaders.labels.load(item.id)
return labels
return ctx.dataLoaders.labels.load(item.id)
},
async recommendations(
item: {
id: string
recommendations?: Recommendation[]
},
item: LibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
if (item.recommendations) return item.recommendations
const recommendations = await ctx.dataLoaders.recommendations.load(
item.id
)
return recommendations.map(recommandationDataToRecommendation)
return ctx.dataLoaders.recommendations.load(item.id)
},
async aiSummary(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) {
async aiSummary(
item: LibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
return (
await getAISummary({
userId: ctx.uid,
@ -560,32 +546,26 @@ export const functionResolvers = {
)?.summary
},
async highlights(
item: {
id: string
highlights?: Highlight[]
},
item: LibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
if (item.highlights) return item.highlights
const highlights = await ctx.dataLoaders.highlights.load(item.id)
return highlights.map(highlightDataToHighlight)
return ctx.dataLoaders.highlights.load(item.id)
},
...readingProgressHandlers,
async content(
item: {
id: string
content?: string
highlightAnnotations?: string[]
format?: ArticleFormat
},
item: PartialLibraryItem,
_: unknown,
ctx: WithDataSourcesContext
) {
// convert html to the requested format if requested
if (item.format && item.format !== ArticleFormat.Html && item.content) {
let highlights: HighlightEntity[] = []
if (
item.format &&
item.format !== ArticleFormat.Html &&
item.readableContent
) {
let highlights: Highlight[] = []
// load highlights if needed
if (
item.format === ArticleFormat.HighlightedMarkdown &&
@ -600,15 +580,18 @@ export const functionResolvers = {
// convert html to the requested format
const converter = contentConverter(item.format)
if (converter) {
return converter(item.content, highlights)
return converter(item.readableContent, highlights)
}
} catch (error) {
ctx.log.error('Error converting content', error)
}
}
return item.content
return item.readableContent
},
isArchived: (item: LibraryItem) => !!item.archivedAt,
pageType: (item: LibraryItem) => item.itemType,
...readingProgressHandlers,
},
Subscription: {
newsletterEmail(subscription: Subscription) {
@ -783,37 +766,43 @@ export const functionResolvers = {
}
},
},
ArticleSavingRequest: {
status: (item: LibraryItem) => item.state,
url: (item: LibraryItem) => item.originalUrl,
},
Recommendation: {
user: (recommendation: Recommendation) => {
return {
userId: recommendation.recommender.id,
username: recommendation.recommender.profile.username,
profileImageURL: recommendation.recommender.profile.pictureUrl,
name: recommendation.recommender.name,
}
},
name: (recommendation: Recommendation) => recommendation.group.name,
recommendedAt: (recommendation: Recommendation) => recommendation.createdAt,
},
...resultResolveTypeResolver('Login'),
...resultResolveTypeResolver('LogOut'),
...resultResolveTypeResolver('GoogleSignup'),
...resultResolveTypeResolver('UpdateUser'),
...resultResolveTypeResolver('UpdateUserProfile'),
...resultResolveTypeResolver('Article'),
// ...resultResolveTypeResolver('SharedArticle'),
...resultResolveTypeResolver('Articles'),
...resultResolveTypeResolver('User'),
...resultResolveTypeResolver('Users'),
...resultResolveTypeResolver('SaveArticleReadingProgress'),
// ...resultResolveTypeResolver('FeedArticles'),
...resultResolveTypeResolver('CreateArticle'),
...resultResolveTypeResolver('CreateHighlight'),
// ...resultResolveTypeResolver('CreateReaction'),
// ...resultResolveTypeResolver('DeleteReaction'),
...resultResolveTypeResolver('MergeHighlight'),
...resultResolveTypeResolver('UpdateHighlight'),
...resultResolveTypeResolver('DeleteHighlight'),
...resultResolveTypeResolver('UploadFileRequest'),
// ...resultResolveTypeResolver('SetShareArticle'),
// ...resultResolveTypeResolver('UpdateSharedComment'),
...resultResolveTypeResolver('SetBookmarkArticle'),
// ...resultResolveTypeResolver('SetFollow'),
// ...resultResolveTypeResolver('GetFollowers'),
// ...resultResolveTypeResolver('GetFollowing'),
...resultResolveTypeResolver('GetUserPersonalization'),
...resultResolveTypeResolver('SetUserPersonalization'),
...resultResolveTypeResolver('ArticleSavingRequest'),
...resultResolveTypeResolver('CreateArticleSavingRequest'),
// ...resultResolveTypeResolver('SetShareHighlight'),
...resultResolveTypeResolver('ArchiveLink'),
...resultResolveTypeResolver('CreateNewsletterEmail'),
...resultResolveTypeResolver('NewsletterEmails'),
@ -886,4 +875,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('Subscription'),
...resultResolveTypeResolver('RefreshHome'),
...resultResolveTypeResolver('HiddenHomeSection'),
...resultResolveTypeResolver('Highlights'),
}

View file

@ -3,7 +3,7 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { DeepPartial } from 'typeorm'
import {
Highlight as HighlightData,
Highlight as HighlightEntity,
HighlightType,
RepresentationType,
} from '../../entity/highlight'
@ -16,6 +16,10 @@ import {
DeleteHighlightError,
DeleteHighlightErrorCode,
DeleteHighlightSuccess,
HighlightEdge,
HighlightsError,
HighlightsErrorCode,
HighlightsSuccess,
MergeHighlightError,
MergeHighlightErrorCode,
MergeHighlightSuccess,
@ -23,6 +27,7 @@ import {
MutationDeleteHighlightArgs,
MutationMergeHighlightArgs,
MutationUpdateHighlightArgs,
QueryHighlightsArgs,
UpdateHighlightError,
UpdateHighlightErrorCode,
UpdateHighlightSuccess,
@ -32,14 +37,15 @@ import {
createHighlight,
deleteHighlightById,
mergeHighlights,
searchHighlights,
updateHighlight,
} from '../../services/highlights'
import { Merge } from '../../util'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/gql-utils'
import { highlightDataToHighlight } from '../../utils/helpers'
export const createHighlightResolver = authorized<
CreateHighlightSuccess,
Merge<CreateHighlightSuccess, { highlight: HighlightEntity }>,
CreateHighlightError,
MutationCreateHighlightArgs
>(async (_, { input }, { log, pubsub, uid }) => {
@ -68,7 +74,7 @@ export const createHighlightResolver = authorized<
},
})
return { highlight: highlightDataToHighlight(newHighlight) }
return { highlight: newHighlight }
} catch (err) {
log.error('Error creating highlight', err)
return {
@ -78,7 +84,7 @@ export const createHighlightResolver = authorized<
})
export const mergeHighlightResolver = authorized<
MergeHighlightSuccess,
Merge<MergeHighlightSuccess, { highlight: HighlightEntity }>,
MergeHighlightError,
MutationMergeHighlightArgs
>(async (_, { input }, { authTrx, log, pubsub, uid }) => {
@ -123,7 +129,7 @@ export const mergeHighlightResolver = authorized<
const color =
newHighlightInput.color || mergedColors[mergedColors.length - 1]
const highlight: DeepPartial<HighlightData> = {
const highlight: DeepPartial<HighlightEntity> = {
...newHighlightInput,
annotation:
mergedAnnotations.length > 0 ? mergedAnnotations.join('\n') : null,
@ -154,7 +160,7 @@ export const mergeHighlightResolver = authorized<
})
return {
highlight: highlightDataToHighlight(newHighlight),
highlight: newHighlight,
overlapHighlightIdList: input.overlapHighlightIdList,
}
} catch (e) {
@ -166,7 +172,7 @@ export const mergeHighlightResolver = authorized<
})
export const updateHighlightResolver = authorized<
UpdateHighlightSuccess,
Merge<UpdateHighlightSuccess, { highlight: HighlightEntity }>,
UpdateHighlightError,
MutationUpdateHighlightArgs
>(async (_, { input }, { pubsub, uid, log }) => {
@ -183,7 +189,7 @@ export const updateHighlightResolver = authorized<
pubsub
)
return { highlight: highlightDataToHighlight(updatedHighlight) }
return { highlight: updatedHighlight }
} catch (error) {
log.error('updateHighlightResolver error', error)
return {
@ -193,7 +199,7 @@ export const updateHighlightResolver = authorized<
})
export const deleteHighlightResolver = authorized<
DeleteHighlightSuccess,
Merge<DeleteHighlightSuccess, { highlight: HighlightEntity }>,
DeleteHighlightError,
MutationDeleteHighlightArgs
>(async (_, { highlightId }, { log }) => {
@ -206,7 +212,7 @@ export const deleteHighlightResolver = authorized<
}
}
return { highlight: highlightDataToHighlight(deletedHighlight) }
return { highlight: deletedHighlight }
} catch (error) {
log.error('deleteHighlightResolver error', error)
return {
@ -215,53 +221,63 @@ export const deleteHighlightResolver = authorized<
}
})
// export const setShareHighlightResolver = authorized<
// SetShareHighlightSuccess,
// SetShareHighlightError,
// MutationSetShareHighlightArgs
// >(async (_, { input: { id, share } }, { pubsub, claims, log }) => {
// const highlight = await getHighlightById(id)
type PartialHighlightEdge = Merge<
HighlightEdge,
{
node: HighlightEntity
}
>
type PartialHighlightsSuccess = Merge<
HighlightsSuccess,
{
edges: PartialHighlightEdge[]
}
>
export const highlightsResolver = authorized<
PartialHighlightsSuccess,
HighlightsError,
QueryHighlightsArgs
>(async (_, { after, first, query }, { uid, log }) => {
const limit = first || 10
const offset = parseInt(after || '0')
if (
isNaN(offset) ||
offset < 0 ||
limit > 50 ||
(query?.length && query.length > 1000)
) {
log.error('Invalid args', { after, first, query })
// if (!highlight?.id) {
// return {
// errorCodes: [SetShareHighlightErrorCode.NotFound],
// }
// }
return {
errorCodes: [HighlightsErrorCode.BadRequest],
}
}
// if (highlight.userId !== claims.uid) {
// return {
// errorCodes: [SetShareHighlightErrorCode.Forbidden],
// }
// }
const highlights = await searchHighlights(
uid,
query || undefined,
limit + 1,
offset
)
// const sharedAt = share ? new Date() : null
const hasNextPage = highlights.length > limit
if (hasNextPage) {
highlights.pop()
}
const endCursor = String(offset + highlights.length)
// log.info(`${share ? 'S' : 'Uns'}haring a highlight`, {
// highlight,
// labels: {
// source: 'resolver',
// resolver: 'setShareHighlightResolver',
// userId: highlight.userId,
// },
// })
const edges = highlights.map((highlight) => ({
cursor: endCursor,
node: highlight,
}))
// const updatedHighlight: HighlightData = {
// ...highlight,
// sharedAt,
// updatedAt: new Date(),
// }
// const updated = await updateHighlight(updatedHighlight, {
// pubsub,
// uid: claims.uid,
// refresh: true,
// })
// if (!updated) {
// return {
// errorCodes: [SetShareHighlightErrorCode.NotFound],
// }
// }
// return { highlight: highlightDataToHighlight(updatedHighlight) }
// })
return {
edges,
pageInfo: {
startCursor: String(offset),
endCursor,
hasPreviousPage: offset > 0,
hasNextPage,
},
}
})

View file

@ -1,5 +1,6 @@
import { In } from 'typeorm'
import { Group } from '../../entity/groups/group'
import { User } from '../../entity/user'
import { env } from '../../env'
import {
CreateGroupError,
@ -19,6 +20,7 @@ import {
MutationLeaveGroupArgs,
MutationRecommendArgs,
MutationRecommendHighlightsArgs,
RecommendationGroup,
RecommendError,
RecommendErrorCode,
RecommendHighlightsError,
@ -38,26 +40,30 @@ import {
leaveGroup,
} from '../../services/groups'
import { findLibraryItemById } from '../../services/library_item'
import { Merge } from '../../util'
import { analytics } from '../../utils/analytics'
import { enqueueRecommendation } from '../../utils/createTask'
import { authorized } from '../../utils/gql-utils'
import { userDataToUser } from '../../utils/helpers'
export type PartialRecommendationGroup = Merge<
RecommendationGroup,
{ admins: Array<User>; members: Array<User> }
>
export const createGroupResolver = authorized<
CreateGroupSuccess,
Merge<CreateGroupSuccess, { group: PartialRecommendationGroup }>,
CreateGroupError,
MutationCreateGroupArgs
>(async (_, { input }, { uid, log }) => {
try {
const userData = await userRepository.findById(uid)
if (!userData) {
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [CreateGroupErrorCode.Unauthorized],
}
}
const [group, invite] = await createGroup({
admin: userData,
admin: user,
name: input.name,
maxMembers: input.maxMembers,
expiresInDays: input.expiresInDays,
@ -80,7 +86,6 @@ export const createGroupResolver = authorized<
await createLabelAndRuleForGroup(uid, group.name)
const inviteUrl = getInviteUrl(invite)
const user = userDataToUser(userData)
return {
group: {
@ -103,37 +108,38 @@ export const createGroupResolver = authorized<
}
})
export const groupsResolver = authorized<GroupsSuccess, GroupsError>(
async (_, __, { uid, log }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [GroupsErrorCode.Unauthorized],
}
}
const groups = await getRecommendationGroups(user)
export const groupsResolver = authorized<
Merge<GroupsSuccess, { groups: Array<PartialRecommendationGroup> }>,
GroupsError
>(async (_, __, { uid, log }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return {
groups,
}
} catch (error) {
log.error('Error getting groups', {
error,
labels: {
source: 'resolver',
resolver: 'groupsResolver',
uid,
},
})
return {
errorCodes: [GroupsErrorCode.BadRequest],
errorCodes: [GroupsErrorCode.Unauthorized],
}
}
const groups = await getRecommendationGroups(user)
return {
groups,
}
} catch (error) {
log.error('Error getting groups', {
error,
labels: {
source: 'resolver',
resolver: 'groupsResolver',
uid,
},
})
return {
errorCodes: [GroupsErrorCode.BadRequest],
}
}
)
})
export const recommendResolver = authorized<
RecommendSuccess,
@ -206,7 +212,7 @@ export const recommendResolver = authorized<
})
export const joinGroupResolver = authorized<
JoinGroupSuccess,
Merge<JoinGroupSuccess, { group: PartialRecommendationGroup }>,
JoinGroupError,
MutationJoinGroupArgs
>(async (_, { inviteCode }, { uid, log }) => {

View file

@ -13,6 +13,7 @@ import { PublicItem } from '../entity/public_item'
import { Recommendation } from '../entity/recommendation'
import { Subscription } from '../entity/subscription'
import { UploadFile } from '../entity/upload_file'
import { User } from '../entity/user'
import { HomeItem } from '../generated/graphql'
import { PubsubClient } from '../pubsub'
@ -58,6 +59,8 @@ export interface RequestContext {
libraryItems: DataLoader<string, LibraryItem | undefined>
publicItems: DataLoader<string, PublicItem | undefined>
subscriptions: DataLoader<string, Subscription | undefined>
users: DataLoader<string, User | undefined>
highlightLabels: DataLoader<string, Label[]>
}
}

View file

@ -1,15 +1,15 @@
import { LibraryItemState } from '../../entity/library_item'
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import {
MutationUpdatePageArgs,
UpdatePageError,
UpdatePageSuccess,
} from '../../generated/graphql'
import { updateLibraryItem } from '../../services/library_item'
import { libraryItemToArticle } from '../../utils/helpers'
import { Merge } from '../../util'
import { authorized } from '../../utils/gql-utils'
export const updatePageResolver = authorized<
UpdatePageSuccess,
Merge<UpdatePageSuccess, { updatedPage: LibraryItem }>,
UpdatePageError,
MutationUpdatePageArgs
>(async (_, { input }, { uid }) => {
@ -29,6 +29,6 @@ export const updatePageResolver = authorized<
uid
)
return {
updatedPage: libraryItemToArticle(updatedPage),
updatedPage: updatedPage,
}
})

View file

@ -33,7 +33,6 @@ import {
UpdateUserProfileErrorCode,
UpdateUserProfileSuccess,
UpdateUserSuccess,
User,
UserErrorCode,
UserResult,
UsersError,
@ -43,13 +42,13 @@ import { userRepository } from '../../repository/user'
import { createUser } from '../../services/create_user'
import { sendAccountChangeEmail } from '../../services/send_emails'
import { softDeleteUser } from '../../services/user'
import { userDataToUser } from '../../utils/helpers'
import { Merge } from '../../util'
import { authorized } from '../../utils/gql-utils'
import { validateUsername } from '../../utils/usernamePolicy'
import { WithDataSourcesContext } from '../types'
import { authorized } from '../../utils/gql-utils'
export const updateUserResolver = authorized<
UpdateUserSuccess,
Merge<UpdateUserSuccess, { user: UserEntity }>,
UpdateUserError,
MutationUpdateUserArgs
>(async (_, { input: { name, bio } }, { uid, authTrx }) => {
@ -83,11 +82,11 @@ export const updateUserResolver = authorized<
})
)
return { user: userDataToUser(updatedUser) }
return { user: updatedUser }
})
export const updateUserProfileResolver = authorized<
UpdateUserProfileSuccess,
Merge<UpdateUserProfileSuccess, { user: UserEntity }>,
UpdateUserProfileError,
MutationUpdateUserProfileArgs
>(async (_, { input: { userId, username, pictureUrl } }, { uid, authTrx }) => {
@ -140,11 +139,11 @@ export const updateUserProfileResolver = authorized<
})
)
return { user: userDataToUser(updatedUser) }
return { user: updatedUser }
})
export const googleLoginResolver: ResolverFn<
LoginResult,
Merge<LoginResult, { me?: UserEntity }>,
unknown,
WithDataSourcesContext,
MutationGoogleLoginArgs
@ -167,7 +166,7 @@ export const googleLoginResolver: ResolverFn<
// set auth cookie in response header
await setAuth({ uid: user.id })
return { me: userDataToUser(user) }
return { me: user }
}
export const validateUsernameResolver: ResolverFn<
@ -190,7 +189,7 @@ export const validateUsernameResolver: ResolverFn<
}
export const googleSignupResolver: ResolverFn<
GoogleSignupResult,
Merge<GoogleSignupResult, { me?: UserEntity }>,
Record<string, unknown>,
WithDataSourcesContext,
MutationGoogleSignupArgs
@ -205,7 +204,7 @@ export const googleSignupResolver: ResolverFn<
}
try {
const [user, profile] = await createUser({
const [user] = await createUser({
email,
sourceUserId,
provider: 'GOOGLE',
@ -218,7 +217,7 @@ export const googleSignupResolver: ResolverFn<
await setAuth({ uid: user.id })
return {
me: userDataToUser({ ...user, profile: { ...profile, private: false } }),
me: user,
}
} catch (err) {
log.info('error signing up with google', err)
@ -245,7 +244,7 @@ export const logOutResolver: ResolverFn<
}
export const getMeUserResolver: ResolverFn<
User | undefined,
UserEntity | undefined,
unknown,
WithDataSourcesContext,
unknown
@ -260,14 +259,14 @@ export const getMeUserResolver: ResolverFn<
return undefined
}
return userDataToUser(user)
return user
} catch (error) {
return undefined
}
}
export const getUserResolver: ResolverFn<
UserResult,
Merge<UserResult, { user?: UserEntity }>,
unknown,
WithDataSourcesContext,
QueryUserArgs
@ -294,16 +293,17 @@ export const getUserResolver: ResolverFn<
return { errorCodes: [UserErrorCode.UserNotFound] }
}
return { user: userDataToUser(userRecord) }
return { user: userRecord }
}
export const getAllUsersResolver = authorized<UsersSuccess, UsersError>(
async (_obj, _params) => {
const users = await userRepository.findTopUsers()
const result = { users: users.map((userData) => userDataToUser(userData)) }
return result
}
)
export const getAllUsersResolver = authorized<
Merge<UsersSuccess, { users: Array<UserEntity> }>,
UsersError
>(async (_obj, _params) => {
const users = await userRepository.findTopUsers()
const result = { users }
return result
})
type ErrorWithCode = {
errorCode: string

View file

@ -49,22 +49,23 @@ export function articleRouter() {
return res.status(400).send('Bad Request')
}
const result = await createPageSaveRequest({ user, url })
if (isSiteBlockedForParse(url)) {
return res
.status(400)
.send({ errorCode: CreateArticleErrorCode.NotAllowedToParse })
}
if (result.errorCode) {
return res.status(400).send({ errorCode: result.errorCode })
}
try {
const result = await createPageSaveRequest({ user, url })
return res.send({
articleSavingRequestId: result.id,
url: result.url,
})
return res.send({
articleSavingRequestId: result.id,
url: result.originalUrl,
})
} catch (error) {
logger.error('Error saving article:', error)
return res.status(500).send({ errorCode: 'INTERNAL_ERROR' })
}
})
router.get(

View file

@ -751,6 +751,7 @@ const schema = gql`
html: String
color: String
representation: RepresentationType!
libraryItem: Article!
}
input CreateHighlightInput {
@ -3226,6 +3227,26 @@ const schema = gql`
PENDING
}
union HighlightsResult = HighlightsSuccess | HighlightsError
type HighlightsSuccess {
edges: [HighlightEdge!]!
pageInfo: PageInfo!
}
type HighlightEdge {
cursor: String!
node: Highlight!
}
type HighlightsError {
errorCodes: [HighlightsErrorCode!]!
}
enum HighlightsErrorCode {
BAD_REQUEST
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -3425,6 +3446,7 @@ const schema = gql`
home(first: Int, after: String): HomeResult!
subscription(id: ID!): SubscriptionResult!
hiddenHomeSection: HiddenHomeSectionResult!
highlights(after: String, first: Int, query: String): HighlightsResult!
}
schema {

View file

@ -1,20 +1,16 @@
import * as privateIpLib from 'private-ip'
import { LibraryItemState } from '../entity/library_item'
import { LibraryItem, LibraryItemState } from '../entity/library_item'
import { User } from '../entity/user'
import {
ArticleSavingRequest,
ArticleSavingRequestStatus,
CreateArticleSavingRequestErrorCode,
CreateLabelInput,
PageType,
} from '../generated/graphql'
import { createPubSubClient, PubsubClient } from '../pubsub'
import { Merge } from '../util'
import { enqueueParseRequest } from '../utils/createTask'
import {
cleanUrl,
generateSlug,
libraryItemToArticleSavingRequest,
} from '../utils/helpers'
import { cleanUrl, generateSlug } from '../utils/helpers'
import { logger } from '../utils/logger'
import { countBySavedAt, createOrUpdateLibraryItem } from './library_item'
@ -88,11 +84,12 @@ export const createPageSaveRequest = async ({
publishedAt,
folder,
subscription,
}: PageSaveRequest): Promise<ArticleSavingRequest> => {
}: PageSaveRequest): Promise<LibraryItem> => {
try {
validateUrl(url)
} catch (error) {
logger.info('invalid url', { url, error })
logger.error('invalid url', { url, error })
return Promise.reject({
errorCode: CreateArticleSavingRequestErrorCode.BadData,
})
@ -140,5 +137,5 @@ export const createPageSaveRequest = async ({
rssFeedUrl: subscription,
})
return libraryItemToArticleSavingRequest(user, libraryItem)
return libraryItem
}

View file

@ -6,9 +6,8 @@ import { Invite } from '../entity/groups/invite'
import { RuleActionType } from '../entity/rule'
import { User } from '../entity/user'
import { homePageURL } from '../env'
import { RecommendationGroup, User as GraphqlUser } from '../generated/graphql'
import { getRepository } from '../repository'
import { userDataToUser } from '../utils/helpers'
import { PartialRecommendationGroup } from '../resolvers'
import { findOrCreateLabels } from './labels'
import { createRule } from './rules'
@ -70,22 +69,21 @@ export const createGroup = async (input: {
export const getRecommendationGroups = async (
user: User
): Promise<RecommendationGroup[]> => {
): Promise<Array<PartialRecommendationGroup>> => {
const groupMembers = await getRepository(GroupMembership).find({
where: { user: { id: user.id } },
relations: ['invite', 'group.members.user.profile'],
})
return groupMembers.map((gm) => {
const admins: GraphqlUser[] = []
const members: GraphqlUser[] = []
const admins: Array<User> = []
const members: Array<User> = []
// Return all members
gm.group.members.forEach((m) => {
const user = userDataToUser(m.user)
if (m.isAdmin) {
admins.push(user)
admins.push(m.user)
}
members.push(user)
members.push(m.user)
})
const canSeeMembers = gm.group.onlyAdminCanSeeMembers ? gm.isAdmin : true
@ -113,7 +111,7 @@ export const getInviteUrl = (invite: Invite) => {
export const joinGroup = async (
user: User,
inviteCode: string
): Promise<RecommendationGroup> => {
): Promise<PartialRecommendationGroup> => {
const invite = await appDataSource.transaction<Invite>(async (t) => {
// Check if the invite exists
const invite = await t
@ -147,15 +145,14 @@ export const joinGroup = async (
where: { id: invite.group.id },
relations: ['members', 'members.user.profile'],
})
const admins: GraphqlUser[] = []
const members: GraphqlUser[] = []
const admins: Array<User> = []
const members: Array<User> = []
// Return all members
group.members.forEach((m) => {
const user = userDataToUser(m.user)
if (m.isAdmin) {
admins.push(user)
admins.push(m.user)
}
members.push(user)
members.push(m.user)
})
return {

View file

@ -205,19 +205,26 @@ export const updateHighlight = async (
return updatedHighlight
}
export const deleteHighlightById = async (highlightId: string) => {
const deletedHighlight = await authTrx(async (tx) => {
const highlightRepo = tx.withRepository(highlightRepository)
const highlight = await highlightRepo.findOneOrFail({
where: { id: highlightId },
relations: {
user: true,
},
})
export const deleteHighlightById = async (
highlightId: string,
userId?: string
) => {
const deletedHighlight = await authTrx(
async (tx) => {
const highlightRepo = tx.withRepository(highlightRepository)
const highlight = await highlightRepo.findOneOrFail({
where: { id: highlightId },
relations: {
user: true,
},
})
await highlightRepo.delete(highlightId)
return highlight
})
await highlightRepo.delete(highlightId)
return highlight
},
undefined,
userId
)
await enqueueUpdateHighlight({
libraryItemId: deletedHighlight.libraryItemId,
@ -227,6 +234,17 @@ export const deleteHighlightById = async (highlightId: string) => {
return deletedHighlight
}
export const deleteHighlightsByIds = async (
userId: string,
highlightIds: string[]
) => {
await authTrx(
async (tx) => tx.getRepository(Highlight).delete(highlightIds),
undefined,
userId
)
}
export const findHighlightById = async (
highlightId: string,
userId: string
@ -260,3 +278,46 @@ export const findHighlightsByLibraryItemId = async (
userId
)
}
export const searchHighlights = async (
userId: string,
query?: string,
limit?: number,
offset?: number
): Promise<Array<Highlight>> => {
return authTrx(
async (tx) => {
const queryBuilder = tx
.getRepository(Highlight)
.createQueryBuilder('highlight')
.andWhere('highlight.userId = :userId', { userId })
.orderBy('highlight.updatedAt', 'DESC')
.take(limit)
.skip(offset)
if (query) {
// parse query and search by it
const labelRegex = /label:"([^"]+)"/g
const labels = Array.from(query.matchAll(labelRegex)).map(
(match) => match[1]
)
labels.forEach((label, index) => {
const alias = `label_${index}`
queryBuilder.innerJoin(
'highlight.labels',
alias,
`LOWER(${alias}.name) = LOWER(:${alias})`,
{
[alias]: label,
}
)
})
}
return queryBuilder.getMany()
},
undefined,
userId
)
}

View file

@ -39,6 +39,23 @@ export const batchGetLabelsFromLibraryItemIds = async (
)
}
export const batchGetLabelsFromHighlightIds = async (
highlightIds: readonly string[]
): Promise<Label[][]> => {
const labels = await authTrx(async (tx) =>
tx.getRepository(EntityLabel).find({
where: { highlightId: In(highlightIds as string[]) },
relations: ['label'],
})
)
return highlightIds.map((highlightId) =>
labels
.filter((label) => label.highlightId === highlightId)
.map((label) => label.label)
)
}
export const findOrCreateLabels = async (
labels: CreateLabelInput[],
userId: string

View file

@ -18,7 +18,15 @@ import { env } from '../env'
import { BulkActionType, InputMaybe, SortParams } from '../generated/graphql'
import { createPubSubClient, EntityEvent, EntityType } from '../pubsub'
import { redisDataSource } from '../redis_data_source'
import { authTrx, getColumns, queryBuilderToRawSql } from '../repository'
import {
authTrx,
getColumns,
paramtersToObject,
queryBuilderToRawSql,
Select,
Sort,
SortOrder,
} from '../repository'
import { libraryItemRepository } from '../repository/library_item'
import { Merge, PickTuple } from '../util'
import { enqueueBulkUploadContentJob } from '../utils/createTask'
@ -122,22 +130,6 @@ export enum SortBy {
WORDS_COUNT = 'wordscount',
}
export enum SortOrder {
ASCENDING = 'ASC',
DESCENDING = 'DESC',
}
export interface Sort {
by: string
order?: SortOrder
nulls?: 'NULLS FIRST' | 'NULLS LAST'
}
interface Select {
column: string
alias?: string
}
const readingProgressDataSource = new ReadingProgressDataSource()
export const batchGetLibraryItems = async (ids: readonly string[]) => {
@ -197,10 +189,6 @@ const handleNoCase = (value: string) => {
throw new Error(`Unexpected keyword: ${value}`)
}
const paramtersToObject = (parameters: ObjectLiteral[]) => {
return parameters.reduce((a, b) => ({ ...a, ...b }), {})
}
export const sortParamsToSort = (
sortParams: InputMaybe<SortParams> | undefined
) => {

View file

@ -60,7 +60,7 @@ export const findActiveUser = async (id: string): Promise<User | null> => {
return userRepository.findOneBy({ id, status: StatusType.Active })
}
export const findUsersById = async (ids: string[]): Promise<User[]> => {
export const findUsersByIds = async (ids: string[]): Promise<User[]> => {
return userRepository.findBy({ id: In(ids) })
}

View file

@ -7,30 +7,11 @@ import path from 'path'
import _ from 'underscore'
import slugify from 'voca/slugify'
import wordsCounter from 'word-counting'
import { Highlight as HighlightData } from '../entity/highlight'
import { LibraryItem, LibraryItemState } from '../entity/library_item'
import { Recommendation as RecommendationData } from '../entity/recommendation'
import { RegistrationType, User } from '../entity/user'
import {
Article,
ArticleSavingRequest,
ArticleSavingRequestStatus,
ContentReader,
CreateArticleError,
CreateArticleSuccess,
DirectionalityType,
FeedArticle,
Highlight,
PageType,
Profile,
Recommendation,
SearchItem,
} from '../generated/graphql'
import { CreateArticleError } from '../generated/graphql'
import { createPubSubClient } from '../pubsub'
import { ArticleFormat } from '../resolvers'
import { validateUrl } from '../services/create_page_save_request'
import { updateLibraryItem } from '../services/library_item'
import { Merge } from '../util'
import { logger } from './logger'
interface InputObject {
@ -101,55 +82,6 @@ export const findDelimiter = (
return delimiter || defaultDelimiter
}
// FIXME: Remove this Date stub after nullable types will be fixed
export const userDataToUser = (
user: Merge<
User,
{
isFriend?: boolean
followersCount?: number
friendsCount?: number
sharedArticlesCount?: number
sharedHighlightsCount?: number
sharedNotesCount?: number
viewerIsFollowing?: boolean
}
>
): {
id: string
name: string
source: RegistrationType
email?: string | null
phone?: string | null
picture?: string | null
googleId?: string | null
createdAt: Date
isFriend?: boolean | null
isFullUser: boolean
viewerIsFollowing?: boolean | null
sourceUserId: string
friendsCount?: number
followersCount?: number
sharedArticles: FeedArticle[]
sharedArticlesCount?: number
sharedHighlightsCount?: number
sharedNotesCount?: number
profile: Profile
} => ({
...user,
source: user.source as RegistrationType,
createdAt: user.createdAt,
friendsCount: user.friendsCount || 0,
followersCount: user.followersCount || 0,
isFullUser: true,
viewerIsFollowing: user.viewerIsFollowing || user.isFriend || false,
picture: user.profile.pictureUrl,
sharedArticles: [],
sharedArticlesCount: user.sharedArticlesCount || 0,
sharedHighlightsCount: user.sharedHighlightsCount || 0,
sharedNotesCount: user.sharedNotesCount || 0,
})
export const generateSlug = (title: string): string => {
return slugify(title).substring(0, 64) + '-' + Date.now().toString(16)
}
@ -161,7 +93,7 @@ export const errorHandler = async (
userId: string,
pageId?: string | null,
pubsub = createPubSubClient()
): Promise<CreateArticleError | CreateArticleSuccess> => {
): Promise<CreateArticleError> => {
if (!pageId) return result
await updateLibraryItem(
@ -176,86 +108,6 @@ export const errorHandler = async (
return result
}
export const highlightDataToHighlight = (
highlight: HighlightData
): Highlight => ({
...highlight,
createdByMe: false,
reactions: [],
replies: [],
type: highlight.highlightType,
user: userDataToUser(highlight.user),
})
export const recommandationDataToRecommendation = (
recommendation: RecommendationData
): Recommendation => ({
...recommendation,
user: {
userId: recommendation.recommender.id,
username: recommendation.recommender.profile.username,
profileImageURL: recommendation.recommender.profile.pictureUrl,
name: recommendation.recommender.name,
},
name: recommendation.group.name,
recommendedAt: recommendation.createdAt,
})
export const libraryItemToArticleSavingRequest = (
user: User,
item: LibraryItem
): ArticleSavingRequest => ({
...item,
user: userDataToUser(user),
status: item.state as unknown as ArticleSavingRequestStatus,
url: item.originalUrl,
userId: user.id,
})
export const libraryItemToArticle = (item: LibraryItem): Article => ({
...item,
url: item.originalUrl,
state: item.state as unknown as ArticleSavingRequestStatus,
content: item.readableContent,
hash: item.textContentHash || '',
isArchived: !!item.archivedAt,
recommendations: item.recommendations?.map(
recommandationDataToRecommendation
),
image: item.thumbnail,
contentReader: item.contentReader as unknown as ContentReader,
readingProgressAnchorIndex: item.readingProgressHighestReadAnchor,
readingProgressPercent: item.readingProgressBottomPercent,
highlights: item.highlights?.map(highlightDataToHighlight) || [],
uploadFileId: item.uploadFile?.id,
pageType: item.itemType as unknown as PageType,
wordsCount: item.wordCount,
directionality: item.directionality as unknown as DirectionalityType,
})
export const libraryItemToSearchItem = (
item: LibraryItem,
format?: ArticleFormat
): SearchItem => ({
...item,
url: item.originalUrl,
state: item.state as unknown as ArticleSavingRequestStatus,
content: item.readableContent,
isArchived: !!item.archivedAt,
pageType: item.itemType as unknown as PageType,
readingProgressPercent: item.readingProgressBottomPercent,
contentReader: item.contentReader as unknown as ContentReader,
readingProgressAnchorIndex: item.readingProgressHighestReadAnchor,
recommendations: item.recommendations?.map(
recommandationDataToRecommendation
),
image: item.thumbnail,
highlights: item.highlights?.map(highlightDataToHighlight),
wordsCount: item.wordCount,
directionality: item.directionality as unknown as DirectionalityType,
format,
})
export const isParsingTimeout = (libraryItem: LibraryItem): boolean => {
return (
// item processed more than 30 seconds ago

View file

@ -3,13 +3,20 @@ import * as chai from 'chai'
import { expect } from 'chai'
import chaiString from 'chai-string'
import 'mocha'
import { Highlight } from '../../src/entity/highlight'
import { User } from '../../src/entity/user'
import { HighlightEdge } from '../../src/generated/graphql'
import {
createHighlight,
deleteHighlightById,
deleteHighlightsByIds,
findHighlightById,
} from '../../src/services/highlights'
import { createLabel, saveLabelsInHighlight } from '../../src/services/labels'
import {
createLabel,
deleteLabels,
saveLabelsInHighlight,
} from '../../src/services/labels'
import { deleteUser } from '../../src/services/user'
import { createTestLibraryItem, createTestUser } from '../db'
import {
@ -165,8 +172,14 @@ describe('Highlights API', () => {
})
context('createHighlightMutation', () => {
let highlightId: string
afterEach(async () => {
await deleteHighlightById(highlightId, user.id)
})
it('does not fail', async () => {
const highlightId = generateFakeUuid()
highlightId = generateFakeUuid()
const shortHighlightId = '_short_id'
const highlightPositionPercent = 35.0
const highlightPositionAnchorIndex = 15
@ -194,31 +207,29 @@ describe('Highlights API', () => {
context('when highlight position is null', () => {
it('sets highlight position = 0', async () => {
const newHighlightId = generateFakeUuid()
highlightId = generateFakeUuid()
const newShortHighlightId = '_short_id_5'
const query = createHighlightQuery(
itemId,
newHighlightId,
highlightId,
newShortHighlightId
)
const res = await graphqlRequest(query, authToken).expect(200)
expect(
res.body.data.createHighlight.highlight.highlightPositionPercent
).to.eq(0)
await deleteHighlightById(newHighlightId)
})
})
context('when the annotation has HTML reserved characters', () => {
it('unescapes the annotation and creates', async () => {
const newHighlightId = generateFakeUuid()
highlightId = generateFakeUuid()
const newShortHighlightId = '_short_id_4'
const highlightPositionPercent = 50.0
const highlightPositionAnchorIndex = 25
const query = createHighlightQuery(
itemId,
newHighlightId,
highlightId,
newShortHighlightId,
highlightPositionPercent,
highlightPositionAnchorIndex,
@ -244,7 +255,7 @@ describe('Highlights API', () => {
})
afterEach(async () => {
await deleteHighlightById(highlightId)
await deleteHighlightById(highlightId, user.id)
})
it('should not fail', async () => {
@ -318,6 +329,10 @@ describe('Highlights API', () => {
highlightId = highlight.id
})
after(async () => {
await deleteHighlightById(highlightId, user.id)
})
it('updates the quote when the quote is in HTML format when the annotation has HTML reserved characters', async () => {
const quote = '> This is a test'
const query = updateHighlightQuery({ highlightId, quote })
@ -344,4 +359,121 @@ describe('Highlights API', () => {
)
})
})
describe('Get highlights API', () => {
const query = `
query Highlights ($first: Int, $after: String, $query: String) {
highlights (first: $first, after: $after, query: $query) {
... on HighlightsSuccess {
edges {
node {
id
user {
id
name
}
labels {
id
name
color
}
libraryItem {
id
title
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
... on HighlightsError {
errorCodes
}
}
}
`
let existingHighlights: Highlight[]
before(async () => {
// create test library item
const item = await createTestLibraryItem(user.id)
// create test highlights
const highlight1 = await createHighlight(
{
libraryItem: { id: item.id },
shortId: generateFakeShortId(),
user: { id: user.id },
},
itemId,
user.id
)
const highlight2 = await createHighlight(
{
libraryItem: { id: item.id },
shortId: generateFakeShortId(),
user: { id: user.id },
},
itemId,
user.id
)
existingHighlights = [highlight1, highlight2]
})
after(async () => {
await deleteHighlightsByIds(
user.id,
existingHighlights.map((h) => h.id)
)
})
it('returns highlights in descending order', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
const highlights = res.body.data.highlights.edges as Array<HighlightEdge>
expect(highlights).to.have.lengthOf(existingHighlights.length)
expect(highlights[0].node.id).to.eq(existingHighlights[1].id)
expect(highlights[1].node.id).to.eq(existingHighlights[0].id)
expect(highlights[0].node.user.id).to.eq(user.id)
expect(highlights[1].node.libraryItem.id).to.eq(
existingHighlights[0].libraryItemId
)
})
it('returns highlights with pagination', async () => {
const res = await graphqlRequest(query, authToken, {
first: 1,
}).expect(200)
const highlights = res.body.data.highlights.edges as Array<HighlightEdge>
expect(highlights).to.have.lengthOf(1)
})
it('returns highlights with labels', async () => {
// create labels
const labelName = 'test_label'
const label = await createLabel(labelName, '#ff0000', user.id)
const labelName1 = 'test_label_1'
const label1 = await createLabel(labelName1, '#ff0001', user.id)
// save labels in highlights
await saveLabelsInHighlight(
[label, label1],
existingHighlights[0].id,
user.id
)
const res = await graphqlRequest(query, authToken, {
query: `label:"${labelName}" label:"${labelName1}"`,
}).expect(200)
const highlights = res.body.data.highlights.edges as Array<HighlightEdge>
expect(highlights).to.have.lengthOf(1)
expect(highlights[0].node.labels?.[0].name).to.eq(labelName)
expect(highlights[0].node.labels?.[1].name).to.eq(labelName1)
await deleteLabels([label.id, label1.id], user.id)
})
})
})

View file

@ -5,7 +5,7 @@ import { StatusType } from '../../src/entity/user'
import {
createUsers,
deleteUsers,
findUsersById,
findUsersByIds,
} from '../../src/services/user'
import { request } from '../util'
@ -58,7 +58,7 @@ describe('User Service Router', () => {
.send(data)
.expect(200)
const deletedUsers = await findUsersById(toDeleteUserIds)
const deletedUsers = await findUsersByIds(toDeleteUserIds)
expect(deletedUsers.length).to.equal(0)
})
})

View file

@ -0,0 +1,5 @@
-- Type: DO
-- Name: add_index_on_highlight_user_id
-- Description: Add index on user_id column to the highlight table
CREATE INDEX CONCURRENTLY IF NOT EXISTS highlight_user_id_idx ON omnivore.highlight (user_id);

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: add_index_on_highlight_user_id
-- Description: Add index on user_id column to the highlight table
BEGIN;
DROP INDEX IF EXISTS highlight_user_id_idx;
COMMIT;