From de3623d3f2506cceae3d4f866c08c326d559ef27 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 8 Jun 2023 13:05:30 +0800 Subject: [PATCH] allow mark as read and add labels bulk actions --- packages/api/src/elastic/pages.ts | 283 +++++++++++--------- packages/api/src/generated/graphql.ts | 31 +-- packages/api/src/generated/schema.graphql | 22 +- packages/api/src/resolvers/article/index.ts | 36 ++- packages/api/src/schema.ts | 26 +- 5 files changed, 197 insertions(+), 201 deletions(-) diff --git a/packages/api/src/elastic/pages.ts b/packages/api/src/elastic/pages.ts index 8b39cf200..117a7ffc9 100644 --- a/packages/api/src/elastic/pages.ts +++ b/packages/api/src/elastic/pages.ts @@ -18,6 +18,7 @@ import { import { client, INDEX_ALIAS } from './index' import { ArticleSavingRequestStatus, + Label, Page, PageContext, PageSearchArgs, @@ -424,103 +425,111 @@ export const getPageById = async (id: string): Promise => { } } +const buildSearchBody = (userId: string, args: PageSearchArgs) => { + const { + query, + readFilter = ReadFilter.ALL, + typeFilter, + labelFilters, + inFilter = InFilter.ALL, + hasFilters, + dateFilters, + termFilters, + matchFilters, + ids, + noFilters, + siteName, + } = args + + const includeLabels = labelFilters?.filter( + (filter) => filter.type === LabelFilterType.INCLUDE + ) + const excludeLabels = labelFilters?.filter( + (filter) => filter.type === LabelFilterType.EXCLUDE + ) + + // start building the query + let builder = esBuilder().query('term', { userId }) + + // append filters + if (query) { + builder = appendQuery(builder, query) + } + if (typeFilter) { + builder = appendTypeFilter(builder, typeFilter) + } + if (inFilter !== InFilter.ALL) { + builder = appendInFilter(builder, inFilter) + } + if (readFilter !== ReadFilter.ALL) { + builder = appendReadFilter(builder, readFilter) + } + if (hasFilters && hasFilters.length > 0) { + builder = appendHasFilters(builder, hasFilters) + } + if (includeLabels && includeLabels.length > 0) { + builder = appendIncludeLabelFilter(builder, includeLabels) + } + if (excludeLabels && excludeLabels.length > 0) { + builder = appendExcludeLabelFilter(builder, excludeLabels) + } + if (dateFilters && dateFilters.length > 0) { + builder = appendDateFilters(builder, dateFilters) + } + if (termFilters) { + builder = appendTermFilters(builder, termFilters) + } + if (matchFilters) { + builder = appendMatchFilters(builder, matchFilters) + } + if (ids && ids.length > 0) { + builder = appendIdsFilter(builder, ids) + } + if (args.recommendedBy) { + builder = appendRecommendedBy(builder, args.recommendedBy) + } + if (!args.includePending) { + builder = builder.notQuery('term', { + state: ArticleSavingRequestStatus.Processing, + }) + } + if (!args.includeDeleted) { + builder = builder.notQuery('term', { + state: ArticleSavingRequestStatus.Deleted, + }) + } + if (noFilters) { + builder = appendNoFilters(builder, noFilters) + } + if (siteName) { + builder = appendSiteNameFilter(builder, siteName) + } + + return builder +} + export const searchPages = async ( args: PageSearchArgs, userId: string ): Promise<[Page[], number] | undefined> => { try { - const { - from = 0, - size = 10, - sort, - query, - readFilter = ReadFilter.ALL, - typeFilter, - labelFilters, - inFilter = InFilter.ALL, - hasFilters, - dateFilters, - termFilters, - matchFilters, - ids, - includeContent, - noFilters, - siteName, - } = args + const { from = 0, size = 10, sort, includeContent } = args + // default order is descending const sortOrder = sort?.order || SortOrder.DESCENDING // default sort by saved_at const sortField = sort?.by || SortBy.SAVED - const includeLabels = labelFilters?.filter( - (filter) => filter.type === LabelFilterType.INCLUDE - ) - const excludeLabels = labelFilters?.filter( - (filter) => filter.type === LabelFilterType.EXCLUDE - ) - // start building the query - let builder = esBuilder() - .query('term', { userId }) + + // build the query + const builder = buildSearchBody(userId, args) + const body = builder .sort(sortField, sortOrder) .from(from) .size(size) .rawOption('_source', { excludes: includeContent ? [] : ['originalHtml', 'content'], }) - // append filters - if (query) { - builder = appendQuery(builder, query) - } - if (typeFilter) { - builder = appendTypeFilter(builder, typeFilter) - } - if (inFilter !== InFilter.ALL) { - builder = appendInFilter(builder, inFilter) - } - if (readFilter !== ReadFilter.ALL) { - builder = appendReadFilter(builder, readFilter) - } - if (hasFilters && hasFilters.length > 0) { - builder = appendHasFilters(builder, hasFilters) - } - if (includeLabels && includeLabels.length > 0) { - builder = appendIncludeLabelFilter(builder, includeLabels) - } - if (excludeLabels && excludeLabels.length > 0) { - builder = appendExcludeLabelFilter(builder, excludeLabels) - } - if (dateFilters && dateFilters.length > 0) { - builder = appendDateFilters(builder, dateFilters) - } - if (termFilters) { - builder = appendTermFilters(builder, termFilters) - } - if (matchFilters) { - builder = appendMatchFilters(builder, matchFilters) - } - if (ids && ids.length > 0) { - builder = appendIdsFilter(builder, ids) - } - if (args.recommendedBy) { - builder = appendRecommendedBy(builder, args.recommendedBy) - } - if (!args.includePending) { - builder = builder.notQuery('term', { - state: ArticleSavingRequestStatus.Processing, - }) - } - if (!args.includeDeleted) { - builder = builder.notQuery('term', { - state: ArticleSavingRequestStatus.Deleted, - }) - } - if (noFilters) { - builder = appendNoFilters(builder, noFilters) - } - if (siteName) { - builder = appendSiteNameFilter(builder, siteName) - } - // build the query - const body = builder.build() + .build() console.debug('searching pages in elastic', JSON.stringify(body)) const response = await client.search, BuiltQuery>({ @@ -688,61 +697,83 @@ export const searchAsYouType = async ( export const updatePagesAsync = async ( userId: string, - action: BulkActionType + action: BulkActionType, + args: PageSearchArgs, + labels?: Label[] ): Promise => { - // default action is archive - let must_not = [ - { - exists: { - field: 'archivedAt', - }, - }, - ] - let params: Record = { archivedAt: new Date() } - if (action === BulkActionType.Delete) { - must_not = [] - params = { state: ArticleSavingRequestStatus.Deleted } + // build the script + let script = { + source: '', + params: {}, } - // get update field - const field = Object.keys(params)[0] + switch (action) { + case BulkActionType.Archive: + script = { + source: `ctx._source.archivedAt = params.archivedAt`, + params: { + archivedAt: new Date(), + }, + } + break + case BulkActionType.Delete: + script = { + source: `ctx._source.state = params.state`, + params: { + state: ArticleSavingRequestStatus.Deleted, + }, + } + break + case BulkActionType.AddLabels: + script = { + source: `ctx._source.labels.addAll(params.labels)`, + params: { + labels, + }, + } + break + case BulkActionType.MarkAsRead: + script = { + source: `ctx._source.readAt = params.readAt; + ctx._source.readingProgressPercent = params.readingProgressPercent; + ctx._source.readingProgressTopPercent = params.readingProgressTopPercent`, + params: { + readAt: new Date(), + readingProgressPercent: 100, + readingProgressTopPercent: 100, + }, + } + break + default: + throw new Error('Invalid bulk action') + } + + // add updatedAt to the script + const updatedScript = { + source: `${script.source}; ctx._source.updatedAt = params.updatedAt`, + lang: 'painless', + params: { + ...script.params, + updatedAt: new Date(), + }, + } + + // build the query + const searchBody = buildSearchBody(userId, args) + .rawOption('script', updatedScript) + .build() + + console.debug('updating pages in elastic', JSON.stringify(searchBody)) try { const { body } = await client.updateByQuery({ index: INDEX_ALIAS, conflicts: 'proceed', wait_for_completion: false, - body: { - query: { - bool: { - filter: [ - { - term: { - userId, - }, - }, - { - terms: { - state: [ - ArticleSavingRequestStatus.Succeeded, - ArticleSavingRequestStatus.Failed, - ArticleSavingRequestStatus.Processing, - ], - }, - }, - ], - must_not, - }, - }, - script: { - source: `ctx._source.${field} = params.${field}`, - lang: 'painless', - params, - }, - }, + body: searchBody, }) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (body.failures?.length > 0) { + if (body.failures && body.failures.length > 0) { console.log('failed to update pages in elastic', body.failures) return null } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index ef690965c..d295a55a0 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -222,34 +222,16 @@ export type ArticlesSuccess = { pageInfo: PageInfo; }; -export type BulkAction = { - itemIds: Array; - itemType: BulkActionItemType; - labelIds?: InputMaybe>; - type: BulkActionType; -}; - export type BulkActionError = { __typename?: 'BulkActionError'; errorCodes: Array; }; export enum BulkActionErrorCode { + BadRequest = 'BAD_REQUEST', Unauthorized = 'UNAUTHORIZED' } -export type BulkActionInput = { - actions: Array; - query?: InputMaybe; -}; - -export enum BulkActionItemType { - Highlight = 'HIGHLIGHT', - Label = 'LABEL', - Page = 'PAGE', - Subscription = 'SUBSCRIPTION' -} - export type BulkActionResult = BulkActionError | BulkActionSuccess; export type BulkActionSuccess = { @@ -1319,7 +1301,9 @@ export type MutationAddPopularReadArgs = { export type MutationBulkActionArgs = { - input: BulkActionInput; + action: BulkActionType; + labelIds?: InputMaybe>; + query: Scalars['String']; }; @@ -3313,11 +3297,8 @@ export type ResolversTypes = { ArticlesResult: ResolversTypes['ArticlesError'] | ResolversTypes['ArticlesSuccess']; ArticlesSuccess: ResolverTypeWrapper; Boolean: ResolverTypeWrapper; - BulkAction: BulkAction; BulkActionError: ResolverTypeWrapper; BulkActionErrorCode: BulkActionErrorCode; - BulkActionInput: BulkActionInput; - BulkActionItemType: BulkActionItemType; BulkActionResult: ResolversTypes['BulkActionError'] | ResolversTypes['BulkActionSuccess']; BulkActionSuccess: ResolverTypeWrapper; BulkActionType: BulkActionType; @@ -3796,9 +3777,7 @@ export type ResolversParentTypes = { ArticlesResult: ResolversParentTypes['ArticlesError'] | ResolversParentTypes['ArticlesSuccess']; ArticlesSuccess: ArticlesSuccess; Boolean: Scalars['Boolean']; - BulkAction: BulkAction; BulkActionError: BulkActionError; - BulkActionInput: BulkActionInput; BulkActionResult: ResolversParentTypes['BulkActionError'] | ResolversParentTypes['BulkActionSuccess']; BulkActionSuccess: BulkActionSuccess; CreateArticleError: CreateArticleError; @@ -5037,7 +5016,7 @@ export type MoveLabelSuccessResolvers = { addPopularRead?: Resolver>; - bulkAction?: Resolver>; + bulkAction?: Resolver>; createArticle?: Resolver>; createArticleSavingRequest?: Resolver>; createGroup?: Resolver>; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index faca55a26..8cc4a7fe2 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -183,33 +183,15 @@ type ArticlesSuccess { pageInfo: PageInfo! } -input BulkAction { - itemIds: [ID!]! - itemType: BulkActionItemType! - labelIds: [ID!] - type: BulkActionType! -} - type BulkActionError { errorCodes: [BulkActionErrorCode!]! } enum BulkActionErrorCode { + BAD_REQUEST UNAUTHORIZED } -input BulkActionInput { - actions: [BulkAction!]! - query: String -} - -enum BulkActionItemType { - HIGHLIGHT - LABEL - PAGE - SUBSCRIPTION -} - union BulkActionResult = BulkActionError | BulkActionSuccess type BulkActionSuccess { @@ -1111,7 +1093,7 @@ type MoveLabelSuccess { type Mutation { addPopularRead(name: String!): AddPopularReadResult! - bulkAction(input: BulkActionInput!): BulkActionResult! + bulkAction(action: BulkActionType!, labelIds: [ID!], query: String!): BulkActionResult! createArticle(input: CreateArticleInput!): CreateArticleResult! createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult! createGroup(input: CreateGroupInput!): CreateGroupResult! diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index d9706bdfa..e50c5de46 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -31,6 +31,7 @@ import { BulkActionError, BulkActionErrorCode, BulkActionSuccess, + BulkActionType, ContentReader, CreateArticleError, CreateArticleErrorCode, @@ -73,7 +74,7 @@ import { UpdatesSinceSuccess, } from '../../generated/graphql' import { createPageSaveRequest } from '../../services/create_page_save_request' -import { createLabels } from '../../services/labels' +import { createLabels, getLabelsByIds } from '../../services/labels' import { parsedContentToPage } from '../../services/save_page' import { traceAs } from '../../tracing' import { Merge } from '../../util' @@ -1097,26 +1098,43 @@ export const bulkActionResolver = authorized< BulkActionSuccess, BulkActionError, MutationBulkActionArgs ->(async (_parent, { action }, { claims: { uid }, log }) => { +>(async (_parent, { query, action, labelIds }, { claims: { uid }, log }) => { log.info('bulkActionResolver') - if (!uid) { - log.error('bulkActionResolver', { error: 'Unauthorized' }) - return { errorCodes: [BulkActionErrorCode.Unauthorized] } - } - analytics.track({ userId: uid, event: 'BulkAction', properties: { env: env.server.apiEnv, + action, }, }) - // TODO: get search filters from query + if (!uid) { + log.log('bulkActionResolver', { error: 'Unauthorized' }) + return { errorCodes: [BulkActionErrorCode.Unauthorized] } + } + + if (!query) { + log.log('bulkActionResolver', { error: 'no query' }) + return { errorCodes: [BulkActionErrorCode.BadRequest] } + } + + // get labels if needed + let labels = undefined + if (action === BulkActionType.AddLabels) { + if (!labelIds || labelIds.length === 0) { + return { errorCodes: [BulkActionErrorCode.BadRequest] } + } + + labels = await getLabelsByIds(uid, labelIds) + } + + // parse query + const searchQuery = parseSearchQuery(query) // start a task to update pages - const taskId = await updatePagesAsync(uid, action) + const taskId = await updatePagesAsync(uid, action, searchQuery, labels) return { success: !!taskId } }) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index ee6c2d6dc..317046181 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2420,25 +2420,6 @@ const schema = gql` ADD_LABELS } - input BulkAction { - itemType: BulkActionItemType! - type: BulkActionType! - itemIds: [ID!]! - labelIds: [ID!] - } - - enum BulkActionItemType { - PAGE - HIGHLIGHT - LABEL - SUBSCRIPTION - } - - input BulkActionInput { - query: String - actions: [BulkAction!]! - } - union BulkActionResult = BulkActionSuccess | BulkActionError type BulkActionSuccess { @@ -2451,6 +2432,7 @@ const schema = gql` enum BulkActionErrorCode { UNAUTHORIZED + BAD_REQUEST } union ImportFromIntegrationResult = @@ -2559,7 +2541,11 @@ const schema = gql` contentType: String! ): UploadImportFileResult! markEmailAsItem(recentEmailId: ID!): MarkEmailAsItemResult! - bulkAction(input: BulkActionInput!): BulkActionResult! + bulkAction( + query: String! + action: BulkActionType! + labelIds: [ID!] + ): BulkActionResult! importFromIntegration(integrationId: ID!): ImportFromIntegrationResult! }