From b4911639c7c7650bc4691b5effe8329e378d685b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 14:00:20 +0800 Subject: [PATCH 01/33] stub create digest job --- packages/api/src/jobs/create_digest.ts | 18 ++++++++++++++++ packages/api/src/queue-processor.ts | 3 +++ packages/api/src/utils/createTask.ts | 30 ++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 packages/api/src/jobs/create_digest.ts diff --git a/packages/api/src/jobs/create_digest.ts b/packages/api/src/jobs/create_digest.ts new file mode 100644 index 000000000..1e06132c0 --- /dev/null +++ b/packages/api/src/jobs/create_digest.ts @@ -0,0 +1,18 @@ +import { logger } from '../utils/logger' + +export const CREATE_DIGEST_JOB = 'CREATE_DIGEST_JOB' + +export interface CreateDigestJobData { + userId: string +} + +export interface CreateDigestJobResponse { + jobId: string +} + +export const processCreateDigestJob = async (data: CreateDigestJobData) => { + logger.info('processing create digest job', data) + + // do something + await Promise.resolve() +} diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 136a854f4..2670e2777 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -18,6 +18,7 @@ import { TaskState } from './generated/graphql' import { aiSummarize, AI_SUMMARIZE_JOB_NAME } from './jobs/ai-summarize' import { bulkAction, BULK_ACTION_JOB_NAME } from './jobs/bulk_action' import { callWebhook, CALL_WEBHOOK_JOB_NAME } from './jobs/call_webhook' +import { CREATE_DIGEST_JOB, processCreateDigestJob } from './jobs/create_digest' import { findThumbnail, THUMBNAIL_JOB } from './jobs/find_thumbnail' import { exportAllItems, @@ -178,6 +179,8 @@ export const createWorker = (connection: ConnectionOptions) => return saveNewsletterJob(job.data) case FORWARD_EMAIL_JOB: return forwardEmailJob(job.data) + case CREATE_DIGEST_JOB: + return processCreateDigestJob(job.data) default: logger.warning(`[queue-processor] unhandled job: ${job.name}`) } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 001daee64..d8fd2bbf5 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -18,6 +18,11 @@ import { import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize' import { BulkActionData, BULK_ACTION_JOB_NAME } from '../jobs/bulk_action' import { CallWebhookJobData, CALL_WEBHOOK_JOB_NAME } from '../jobs/call_webhook' +import { + CreateDigestJobData, + CreateDigestJobResponse, + CREATE_DIGEST_JOB, +} from '../jobs/create_digest' import { THUMBNAIL_JOB } from '../jobs/find_thumbnail' import { EXPORT_ALL_ITEMS_JOB_NAME } from '../jobs/integration/export_all_items' import { @@ -81,6 +86,7 @@ export const getJobPriority = (jobName: string): number => { case `${REFRESH_FEED_JOB_NAME}_high`: return 10 case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME: + case CREATE_DIGEST_JOB: return 20 case `${REFRESH_FEED_JOB_NAME}_low`: case EXPORT_ITEM_JOB_NAME: @@ -853,4 +859,28 @@ export const enqueueSendEmail = async (jobData: SendEmailJobData) => { }) } +export const enqueueCreateDigest = async ( + data: CreateDigestJobData +): Promise => { + const queue = await getBackendQueue() + if (!queue) { + throw new Error('No queue found') + } + + const jobId = `create-digest-${data.userId}` + const job = await queue.add(CREATE_DIGEST_JOB, data, { + jobId, // dedupe by userId + removeOnComplete: true, + removeOnFail: true, + attempts: 3, + priority: getJobPriority(CREATE_DIGEST_JOB), + }) + + logger.info('create digest job enqueued', { jobId: job.id }) + + return { + jobId, + } +} + export default createHttpTaskWithToken From 12aa7d4447d1699d1704856cb61ab0f9329bfca0 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 15:59:37 +0800 Subject: [PATCH 02/33] create digest job api --- packages/api/package.json | 2 +- packages/api/src/jobs/create_digest.ts | 2 +- packages/api/src/queue-processor.ts | 3 + packages/api/src/routers/digest_router.ts | 69 +++++++++++++++++++++++ packages/api/src/utils/auth.ts | 3 +- packages/api/src/utils/createTask.ts | 4 +- yarn.lock | 8 +-- 7 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 packages/api/src/routers/digest_router.ts diff --git a/packages/api/package.json b/packages/api/package.json index 0da5f742b..469586434 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -124,7 +124,7 @@ "@types/chai-as-promised": "^7.1.5", "@types/chai-string": "^1.4.2", "@types/cookie": "^0.4.0", - "@types/cookie-parser": "^1.4.2", + "@types/cookie-parser": "^1.4.7", "@types/csv-stringify": "^3.1.0", "@types/diff-match-patch": "^1.0.32", "@types/dompurify": "^2.0.4", diff --git a/packages/api/src/jobs/create_digest.ts b/packages/api/src/jobs/create_digest.ts index 1e06132c0..bab980480 100644 --- a/packages/api/src/jobs/create_digest.ts +++ b/packages/api/src/jobs/create_digest.ts @@ -1,6 +1,6 @@ import { logger } from '../utils/logger' -export const CREATE_DIGEST_JOB = 'CREATE_DIGEST_JOB' +export const CREATE_DIGEST_JOB = 'create-digest' export interface CreateDigestJobData { userId: string diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 2670e2777..86712a67b 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -96,6 +96,9 @@ export const getBackendQueue = async (): Promise => { return backendQueue } +export const createJobId = (jobName: string, userId: string) => + `${jobName}_${userId}_${JOB_VERSION}` + export const getJob = async (jobId: string) => { const queue = await getBackendQueue() if (!queue) { diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts new file mode 100644 index 000000000..b54c73ceb --- /dev/null +++ b/packages/api/src/routers/digest_router.ts @@ -0,0 +1,69 @@ +import cors from 'cors' +import express from 'express' +import { CREATE_DIGEST_JOB } from '../jobs/create_digest' +import { createJobId, getJob } from '../queue-processor' +import { findActiveUser } from '../services/user' +import { getClaimsByToken, getTokenByRequest } from '../utils/auth' +import { corsConfig } from '../utils/corsConfig' +import { enqueueCreateDigest } from '../utils/createTask' +import { logger } from '../utils/logger' + +export function digestRouter() { + const router = express.Router() + + // v1 version of create digest api + router.post( + '/v1', + cors(corsConfig), + async (req: express.Request, res) => { + const token = getTokenByRequest(req) + + let userId: string + try { + // get claims from token + const claims = await getClaimsByToken(token) + if (!claims) { + logger.info('Token not found') + return res.sendStatus(401) + } + + // get user by uid from claims + userId = claims.uid + } catch (error) { + logger.info('Error while getting claims from token', error) + return res.sendStatus(401) + } + + const user = await findActiveUser(userId) + if (!user) { + logger.info(`User not found: ${userId}`) + return res.sendStatus(401) + } + + try { + // check if job is already in queue + // if yes then return 409 conflict + // else enqueue job + const jobId = createJobId(userId, CREATE_DIGEST_JOB) + const existingJob = await getJob(jobId) + if (existingJob) { + logger.info(`Job already in queue: ${jobId}`) + return res.sendStatus(409) + } + + // enqueue job and return job id + const result = await enqueueCreateDigest({ + userId, + }) + + // return job id + return res.status(200).send(result) + } catch (error) { + logger.error('Error while enqueuing create digest task', error) + return res.sendStatus(500) + } + } + ) + + return router +} diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index 49c5fe9ff..c3269aec8 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -124,8 +124,7 @@ export const getTokenByRequest = (req: express.Request): string | undefined => { return ( req.header(OmnivoreAuthorizationHeader) || req.headers.authorization || - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (req.cookies?.auth as string) + (req.cookies.auth as string | undefined) ) } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index d8fd2bbf5..7a8a2d23b 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -49,7 +49,7 @@ import { UPDATE_HIGHLIGHT_JOB, UPDATE_LABELS_JOB, } from '../jobs/update_db' -import { getBackendQueue, JOB_VERSION } from '../queue-processor' +import { createJobId, getBackendQueue, JOB_VERSION } from '../queue-processor' import { redisDataSource } from '../redis_data_source' import { signFeatureToken } from '../services/features' import { OmnivoreAuthorizationHeader } from './auth' @@ -867,7 +867,7 @@ export const enqueueCreateDigest = async ( throw new Error('No queue found') } - const jobId = `create-digest-${data.userId}` + const jobId = createJobId(CREATE_DIGEST_JOB, data.userId) const job = await queue.add(CREATE_DIGEST_JOB, data, { jobId, // dedupe by userId removeOnComplete: true, diff --git a/yarn.lock b/yarn.lock index db12191a9..034c6ee5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7767,10 +7767,10 @@ dependencies: "@types/node" "*" -"@types/cookie-parser@^1.4.2": - version "1.4.2" - resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5" - integrity sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg== +"@types/cookie-parser@^1.4.7": + version "1.4.7" + resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.7.tgz#c874471f888c72423d78d2b3c32d1e8579cf3c8f" + integrity sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw== dependencies: "@types/express" "*" From 1edb3419f872b3eda83a4c0c359bdebb9688d244 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 16:22:56 +0800 Subject: [PATCH 03/33] get digest api --- packages/api/src/routers/digest_router.ts | 154 ++++++++++++++++------ 1 file changed, 113 insertions(+), 41 deletions(-) diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index b54c73ceb..151b57364 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -1,69 +1,141 @@ import cors from 'cors' import express from 'express' +import { TaskState } from '../generated/graphql' import { CREATE_DIGEST_JOB } from '../jobs/create_digest' -import { createJobId, getJob } from '../queue-processor' +import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' +import { redisDataSource } from '../redis_data_source' import { findActiveUser } from '../services/user' import { getClaimsByToken, getTokenByRequest } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' import { enqueueCreateDigest } from '../utils/createTask' import { logger } from '../utils/logger' +interface Digest { + url: string + title: string + jobState: string + content: string + chapters: Chapter[] + urlsToAudio: string[] +} + +interface Chapter { + title: string +} + export function digestRouter() { const router = express.Router() // v1 version of create digest api - router.post( - '/v1', - cors(corsConfig), - async (req: express.Request, res) => { - const token = getTokenByRequest(req) + router.post('/v1', cors(corsConfig), async (req, res) => { + const token = getTokenByRequest(req) - let userId: string - try { - // get claims from token - const claims = await getClaimsByToken(token) - if (!claims) { - logger.info('Token not found') - return res.sendStatus(401) - } - - // get user by uid from claims - userId = claims.uid - } catch (error) { - logger.info('Error while getting claims from token', error) + let userId: string + try { + // get claims from token + const claims = await getClaimsByToken(token) + if (!claims) { + logger.info('Token not found') return res.sendStatus(401) } + // get user by uid from claims + userId = claims.uid + } catch (error) { + logger.info('Error while getting claims from token', error) + return res.sendStatus(401) + } + + try { const user = await findActiveUser(userId) if (!user) { logger.info(`User not found: ${userId}`) return res.sendStatus(401) } - try { - // check if job is already in queue - // if yes then return 409 conflict - // else enqueue job - const jobId = createJobId(userId, CREATE_DIGEST_JOB) - const existingJob = await getJob(jobId) - if (existingJob) { - logger.info(`Job already in queue: ${jobId}`) - return res.sendStatus(409) - } - - // enqueue job and return job id - const result = await enqueueCreateDigest({ - userId, - }) - - // return job id - return res.status(200).send(result) - } catch (error) { - logger.error('Error while enqueuing create digest task', error) - return res.sendStatus(500) + // check if job is already in queue + // if yes then return 409 conflict + // else enqueue job + const jobId = createJobId(userId, CREATE_DIGEST_JOB) + const existingJob = await getJob(jobId) + if (existingJob) { + logger.info(`Job already in queue: ${jobId}`) + return res.sendStatus(409) } + + // enqueue job and return job id + const result = await enqueueCreateDigest({ + userId, + }) + + // return job id + return res.status(200).send(result) + } catch (error) { + logger.error('Error while enqueuing create digest task', error) + return res.sendStatus(500) } - ) + }) + + // v1 version of get digest api + router.get('/v1', cors(corsConfig), async (req, res) => { + const token = getTokenByRequest(req) + + let userId: string + try { + // get claims from token + const claims = await getClaimsByToken(token) + if (!claims) { + logger.info('Token not found') + return res.sendStatus(401) + } + + // get user by uid from claims + userId = claims.uid + } catch (error) { + logger.info('Error while getting claims from token', error) + return res.sendStatus(401) + } + + try { + const user = await findActiveUser(userId) + if (!user) { + logger.info(`User not found: ${userId}`) + return res.sendStatus(401) + } + + // get job by user id + const jobId = createJobId(userId, CREATE_DIGEST_JOB) + const job = await getJob(jobId) + if (job) { + // if job is in queue then return job state + const jobState = await job.getState() + return res.send({ + jobId: job.id, + jobState: jobStateToTaskState(jobState), + }) + } + + // if job is done and removed then get the digest from redis + const key = `digest:${userId}` + const digest = await redisDataSource.redisClient?.get(key) + if (!digest) { + logger.info(`Digest not found: ${key}`) + return res.sendStatus(404) + } + + const digestObject = JSON.parse(digest) as Digest + + // return digest + return res.send({ + ...digestObject, + jobId, + jobState: TaskState.Succeeded, + }) + } catch (error) { + logger.error('Error while getting digest', error) + return res.sendStatus(500) + } + }) return router } From e37bcd7408bed28dba719c54dc687df9e6ff9c9b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 16:35:22 +0800 Subject: [PATCH 04/33] send feedback api --- packages/api/src/routers/digest_router.ts | 74 +++++++++++++++++++++++ packages/api/src/server.ts | 2 + 2 files changed, 76 insertions(+) diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 151b57364..ae478af26 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -5,6 +5,7 @@ import { CREATE_DIGEST_JOB } from '../jobs/create_digest' import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' import { redisDataSource } from '../redis_data_source' import { findActiveUser } from '../services/user' +import { analytics } from '../utils/analytics' import { getClaimsByToken, getTokenByRequest } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' import { enqueueCreateDigest } from '../utils/createTask' @@ -23,6 +24,24 @@ interface Chapter { title: string } +interface Feedback { + digestRating: number + rankingRating: number + summaryRating: number + voiceRating: number + musicRating: number +} + +const isFeedback = (data: any): data is Feedback => { + return ( + 'digestRating' in data && + 'rankingRating' in data && + 'summaryRating' in data && + 'voiceRating' in data && + 'musicRating' in data + ) +} + export function digestRouter() { const router = express.Router() @@ -137,5 +156,60 @@ export function digestRouter() { } }) + // v1 version of sending feedback api + router.post( + '/v1/feedback', + cors(corsConfig), + async (req, res) => { + const token = getTokenByRequest(req) + + let userId: string + try { + // get claims from token + const claims = await getClaimsByToken(token) + if (!claims) { + logger.info('Token not found') + return res.sendStatus(401) + } + + // get user by uid from claims + userId = claims.uid + } catch (error) { + logger.info('Error while getting claims from token', error) + return res.sendStatus(401) + } + + try { + const user = await findActiveUser(userId) + if (!user) { + logger.info(`User not found: ${userId}`) + return res.sendStatus(401) + } + + // get feedback from request body + if (!isFeedback(req.body)) { + logger.info('Invalid feedback format') + return res.sendStatus(400) + } + + const feedback = req.body + // send feedback to analytics + logger.info(`Sending feedback: ${JSON.stringify(feedback)}`) + + analytics.capture({ + distinctId: userId, + event: 'digest_feedback', + properties: feedback, + }) + + // return success + return res.sendStatus(200) + } catch (error) { + logger.error('Error while saving feedback', error) + return res.sendStatus(500) + } + } + ) + return router } diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 2c945581c..196c38f7c 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -21,6 +21,7 @@ import { aiSummariesRouter } from './routers/ai_summary_router' import { articleRouter } from './routers/article_router' import { authRouter } from './routers/auth/auth_router' import { mobileAuthRouter } from './routers/auth/mobile/mobile_auth_router' +import { digestRouter } from './routers/digest_router' import { integrationRouter } from './routers/integration_router' import { localDebugRouter } from './routers/local_debug_router' import { notificationRouter } from './routers/notification_router' @@ -89,6 +90,7 @@ export const createApp = (): Express => { app.use('/api/notification', notificationRouter()) app.use('/api/integration', integrationRouter()) app.use('/api/tasks', taskRouter()) + app.use('/api/digest', digestRouter) app.use('/svc/pubsub/content', contentServiceRouter()) app.use('/svc/pubsub/links', linkServiceRouter()) app.use('/svc/pubsub/newsletters', newsletterServiceRouter()) From a47a5a7d9c433877585ed73c135b932b289e30fa Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 16:56:16 +0800 Subject: [PATCH 05/33] mock saving digest in redis --- packages/api/src/jobs/create_digest.ts | 26 ++++++++++++++++-- packages/api/src/routers/digest_router.ts | 24 +++-------------- packages/api/src/server.ts | 2 +- packages/api/src/services/digest.ts | 32 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 packages/api/src/services/digest.ts diff --git a/packages/api/src/jobs/create_digest.ts b/packages/api/src/jobs/create_digest.ts index bab980480..f57c7eab8 100644 --- a/packages/api/src/jobs/create_digest.ts +++ b/packages/api/src/jobs/create_digest.ts @@ -1,3 +1,4 @@ +import { setDigest } from '../services/digest' import { logger } from '../utils/logger' export const CREATE_DIGEST_JOB = 'create-digest' @@ -13,6 +14,27 @@ export interface CreateDigestJobResponse { export const processCreateDigestJob = async (data: CreateDigestJobData) => { logger.info('processing create digest job', data) - // do something - await Promise.resolve() + // TODO: create digest + const digest = { + url: 'https://example.com', + title: 'Example Digest', + jobState: 'completed', + content: 'This is an example digest', + chapters: [ + { + title: 'Chapter 1', + }, + { + title: 'Chapter 2', + }, + ], + urlsToAudio: ['https://example.com/audio'], + } + + // save digest to redis + await setDigest(data.userId, digest) + + logger.info('digest created', digest) + + return true } diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index ae478af26..24102de3b 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -3,7 +3,7 @@ import express from 'express' import { TaskState } from '../generated/graphql' import { CREATE_DIGEST_JOB } from '../jobs/create_digest' import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' -import { redisDataSource } from '../redis_data_source' +import { getDigest } from '../services/digest' import { findActiveUser } from '../services/user' import { analytics } from '../utils/analytics' import { getClaimsByToken, getTokenByRequest } from '../utils/auth' @@ -11,19 +11,6 @@ import { corsConfig } from '../utils/corsConfig' import { enqueueCreateDigest } from '../utils/createTask' import { logger } from '../utils/logger' -interface Digest { - url: string - title: string - jobState: string - content: string - chapters: Chapter[] - urlsToAudio: string[] -} - -interface Chapter { - title: string -} - interface Feedback { digestRating: number rankingRating: number @@ -135,18 +122,15 @@ export function digestRouter() { } // if job is done and removed then get the digest from redis - const key = `digest:${userId}` - const digest = await redisDataSource.redisClient?.get(key) + const digest = await getDigest(userId) if (!digest) { - logger.info(`Digest not found: ${key}`) + logger.info(`Digest not found: ${userId}`) return res.sendStatus(404) } - const digestObject = JSON.parse(digest) as Digest - // return digest return res.send({ - ...digestObject, + ...digest, jobId, jobState: TaskState.Succeeded, }) diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 196c38f7c..e1603a6cb 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -90,7 +90,7 @@ export const createApp = (): Express => { app.use('/api/notification', notificationRouter()) app.use('/api/integration', integrationRouter()) app.use('/api/tasks', taskRouter()) - app.use('/api/digest', digestRouter) + app.use('/api/digest', digestRouter()) app.use('/svc/pubsub/content', contentServiceRouter()) app.use('/svc/pubsub/links', linkServiceRouter()) app.use('/svc/pubsub/newsletters', newsletterServiceRouter()) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts new file mode 100644 index 000000000..ceb274d70 --- /dev/null +++ b/packages/api/src/services/digest.ts @@ -0,0 +1,32 @@ +import { redisDataSource } from '../redis_data_source' + +export interface Digest { + url: string + title: string + jobState: string + content: string + chapters: Chapter[] + urlsToAudio: string[] +} + +interface Chapter { + title: string +} + +const digestKey = (userId: string) => `digest:${userId}` + +export const getDigest = async (userId: string): Promise => { + const digest = await redisDataSource.redisClient?.get(digestKey(userId)) + return digest ? (JSON.parse(digest) as Digest) : null +} + +export const setDigest = async (userId: string, digest: Digest) => { + const result = await redisDataSource.redisClient?.set( + digestKey(userId), + JSON.stringify(digest) + ) + + if (result != 'OK') { + throw new Error('Failed to set digest') + } +} From 521698646dcbcc4b225bdaa0d37d756d9e18b448 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 16:58:56 +0800 Subject: [PATCH 06/33] update digest mock data --- packages/api/src/jobs/create_digest.ts | 20 +++++++++----------- packages/api/src/services/digest.ts | 1 - 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/api/src/jobs/create_digest.ts b/packages/api/src/jobs/create_digest.ts index f57c7eab8..e17fd26b4 100644 --- a/packages/api/src/jobs/create_digest.ts +++ b/packages/api/src/jobs/create_digest.ts @@ -16,19 +16,17 @@ export const processCreateDigestJob = async (data: CreateDigestJobData) => { // TODO: create digest const digest = { - url: 'https://example.com', - title: 'Example Digest', - jobState: 'completed', - content: 'This is an example digest', + url: '', + title: '', + content: '', chapters: [ - { - title: 'Chapter 1', - }, - { - title: 'Chapter 2', - }, + { title: '00:30 - China buying' }, + { title: '10:20 - Twitter for sale' }, + ], + urlsToAudio: [ + 'https://storage.google.com/...', + 'https://api.omnivore.app/api/digest/audio/', ], - urlsToAudio: ['https://example.com/audio'], } // save digest to redis diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index ceb274d70..46343a8ce 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -3,7 +3,6 @@ import { redisDataSource } from '../redis_data_source' export interface Digest { url: string title: string - jobState: string content: string chapters: Chapter[] urlsToAudio: string[] From 4342cdeb3eae323981fe17c67d1deaea50040255 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 17:39:02 +0800 Subject: [PATCH 07/33] expire digest in one day --- packages/api/src/jobs/create_digest.ts | 3 +++ packages/api/src/routers/digest_router.ts | 4 ++-- packages/api/src/services/digest.ts | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/api/src/jobs/create_digest.ts b/packages/api/src/jobs/create_digest.ts index e17fd26b4..fd5b1d842 100644 --- a/packages/api/src/jobs/create_digest.ts +++ b/packages/api/src/jobs/create_digest.ts @@ -32,6 +32,9 @@ export const processCreateDigestJob = async (data: CreateDigestJobData) => { // save digest to redis await setDigest(data.userId, digest) + // simulate long running task + await new Promise((resolve) => setTimeout(resolve, 5000)) + logger.info('digest created', digest) return true diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 24102de3b..f495dc87e 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -62,7 +62,7 @@ export function digestRouter() { // check if job is already in queue // if yes then return 409 conflict // else enqueue job - const jobId = createJobId(userId, CREATE_DIGEST_JOB) + const jobId = createJobId(CREATE_DIGEST_JOB, userId) const existingJob = await getJob(jobId) if (existingJob) { logger.info(`Job already in queue: ${jobId}`) @@ -110,7 +110,7 @@ export function digestRouter() { } // get job by user id - const jobId = createJobId(userId, CREATE_DIGEST_JOB) + const jobId = createJobId(CREATE_DIGEST_JOB, userId) const job = await getJob(jobId) if (job) { // if job is in queue then return job state diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 46343a8ce..8a6160ecc 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -22,7 +22,9 @@ export const getDigest = async (userId: string): Promise => { export const setDigest = async (userId: string, digest: Digest) => { const result = await redisDataSource.redisClient?.set( digestKey(userId), - JSON.stringify(digest) + JSON.stringify(digest), + 'EX', + 60 * 60 * 24 // 1 day ) if (result != 'OK') { From 111dcd1562b5abb8c17f88e0e4e88f9732dd69a4 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 18:32:48 +0800 Subject: [PATCH 08/33] send ranking models and summary models to posthog and log comment --- packages/api/src/jobs/create_digest.ts | 21 +-------------------- packages/api/src/routers/digest_router.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/api/src/jobs/create_digest.ts b/packages/api/src/jobs/create_digest.ts index fd5b1d842..43550ef32 100644 --- a/packages/api/src/jobs/create_digest.ts +++ b/packages/api/src/jobs/create_digest.ts @@ -1,4 +1,3 @@ -import { setDigest } from '../services/digest' import { logger } from '../utils/logger' export const CREATE_DIGEST_JOB = 'create-digest' @@ -14,28 +13,10 @@ export interface CreateDigestJobResponse { export const processCreateDigestJob = async (data: CreateDigestJobData) => { logger.info('processing create digest job', data) - // TODO: create digest - const digest = { - url: '', - title: '', - content: '', - chapters: [ - { title: '00:30 - China buying' }, - { title: '10:20 - Twitter for sale' }, - ], - urlsToAudio: [ - 'https://storage.google.com/...', - 'https://api.omnivore.app/api/digest/audio/', - ], - } - - // save digest to redis - await setDigest(data.userId, digest) - // simulate long running task await new Promise((resolve) => setTimeout(resolve, 5000)) - logger.info('digest created', digest) + logger.info('digest created') return true } diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index f495dc87e..0c7e47ceb 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -13,10 +13,13 @@ import { logger } from '../utils/logger' interface Feedback { digestRating: number + rankingModels: string[] rankingRating: number summaryRating: number + summaryModels: string[] voiceRating: number musicRating: number + comment?: string } const isFeedback = (data: any): data is Feedback => { @@ -60,13 +63,13 @@ export function digestRouter() { } // check if job is already in queue - // if yes then return 409 conflict + // if yes then return 202 accepted // else enqueue job const jobId = createJobId(CREATE_DIGEST_JOB, userId) const existingJob = await getJob(jobId) if (existingJob) { logger.info(`Job already in queue: ${jobId}`) - return res.sendStatus(409) + return res.sendStatus(202) } // enqueue job and return job id @@ -75,7 +78,7 @@ export function digestRouter() { }) // return job id - return res.status(200).send(result) + return res.status(201).send(result) } catch (error) { logger.error('Error while enqueuing create digest task', error) return res.sendStatus(500) @@ -177,9 +180,11 @@ export function digestRouter() { } const feedback = req.body - // send feedback to analytics logger.info(`Sending feedback: ${JSON.stringify(feedback)}`) + // remove comment from feedback before sending to analytics + delete feedback.comment + // send feedback to analytics analytics.capture({ distinctId: userId, event: 'digest_feedback', From bc955fbeeb5e8308e03b6ebc89d04f9a88d26c25 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 18:51:42 +0800 Subject: [PATCH 09/33] remove stub job --- packages/api/src/routers/digest_router.ts | 47 +++++++++-------------- packages/api/src/services/digest.ts | 19 ++++++--- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 0c7e47ceb..519a52688 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -3,12 +3,11 @@ import express from 'express' import { TaskState } from '../generated/graphql' import { CREATE_DIGEST_JOB } from '../jobs/create_digest' import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' -import { getDigest } from '../services/digest' +import { getDigest, setDigest } from '../services/digest' import { findActiveUser } from '../services/user' import { analytics } from '../utils/analytics' import { getClaimsByToken, getTokenByRequest } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' -import { enqueueCreateDigest } from '../utils/createTask' import { logger } from '../utils/logger' interface Feedback { @@ -62,23 +61,24 @@ export function digestRouter() { return res.sendStatus(401) } - // check if job is already in queue + // check if digest job is running // if yes then return 202 accepted // else enqueue job - const jobId = createJobId(CREATE_DIGEST_JOB, userId) - const existingJob = await getJob(jobId) - if (existingJob) { - logger.info(`Job already in queue: ${jobId}`) + const digest = await getDigest(userId) + if (digest?.jobState === TaskState.Running) { + logger.info(`job is running: ${userId}`) return res.sendStatus(202) } // enqueue job and return job id - const result = await enqueueCreateDigest({ - userId, + const jobId = await setDigest(userId, { + jobState: TaskState.Running, }) // return job id - return res.status(201).send(result) + return res.status(201).send({ + jobId, + }) } catch (error) { logger.error('Error while enqueuing create digest task', error) return res.sendStatus(500) @@ -112,31 +112,22 @@ export function digestRouter() { return res.sendStatus(401) } - // get job by user id - const jobId = createJobId(CREATE_DIGEST_JOB, userId) - const job = await getJob(jobId) - if (job) { - // if job is in queue then return job state - const jobState = await job.getState() - return res.send({ - jobId: job.id, - jobState: jobStateToTaskState(jobState), - }) - } - - // if job is done and removed then get the digest from redis + // get digest from redis const digest = await getDigest(userId) if (!digest) { logger.info(`Digest not found: ${userId}`) return res.sendStatus(404) } + if (digest.jobState === TaskState.Running) { + // if job is in queue then return job state + return res.status(202).send({ + jobState: TaskState.Running, + }) + } + // return digest - return res.send({ - ...digest, - jobId, - jobState: TaskState.Succeeded, - }) + return res.send(digest) } catch (error) { logger.error('Error while getting digest', error) return res.sendStatus(500) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 8a6160ecc..41dac920f 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -1,11 +1,12 @@ import { redisDataSource } from '../redis_data_source' export interface Digest { - url: string - title: string - content: string - chapters: Chapter[] - urlsToAudio: string[] + url?: string + title?: string + content?: string + chapters?: Chapter[] + urlsToAudio?: string[] + jobState: string } interface Chapter { @@ -19,7 +20,11 @@ export const getDigest = async (userId: string): Promise => { return digest ? (JSON.parse(digest) as Digest) : null } -export const setDigest = async (userId: string, digest: Digest) => { +export const setDigest = async ( + userId: string, + digest: Digest +): Promise => { + const key = digestKey(userId) const result = await redisDataSource.redisClient?.set( digestKey(userId), JSON.stringify(digest), @@ -30,4 +35,6 @@ export const setDigest = async (userId: string, digest: Digest) => { if (result != 'OK') { throw new Error('Failed to set digest') } + + return key } From cdb0b7525e6bb481132012e9314786f0e251d440 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 12 Apr 2024 18:53:50 +0800 Subject: [PATCH 10/33] cleanup --- packages/api/src/routers/digest_router.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 519a52688..7f9ab327b 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -1,8 +1,6 @@ import cors from 'cors' import express from 'express' import { TaskState } from '../generated/graphql' -import { CREATE_DIGEST_JOB } from '../jobs/create_digest' -import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' import { getDigest, setDigest } from '../services/digest' import { findActiveUser } from '../services/user' import { analytics } from '../utils/analytics' From 03e497b4897b2ef838a10a5f6e07da80cadb8326 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sat, 13 Apr 2024 14:41:10 +0800 Subject: [PATCH 11/33] use a separate queue for digest job --- packages/api/src/jobs/create_digest.ts | 22 --------- packages/api/src/queue-processor.ts | 20 ++++----- packages/api/src/routers/digest_router.ts | 55 ++++++++++++++--------- packages/api/src/services/digest.ts | 29 +++++------- packages/api/src/utils/createTask.ts | 22 ++++----- 5 files changed, 65 insertions(+), 83 deletions(-) delete mode 100644 packages/api/src/jobs/create_digest.ts diff --git a/packages/api/src/jobs/create_digest.ts b/packages/api/src/jobs/create_digest.ts deleted file mode 100644 index 43550ef32..000000000 --- a/packages/api/src/jobs/create_digest.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { logger } from '../utils/logger' - -export const CREATE_DIGEST_JOB = 'create-digest' - -export interface CreateDigestJobData { - userId: string -} - -export interface CreateDigestJobResponse { - jobId: string -} - -export const processCreateDigestJob = async (data: CreateDigestJobData) => { - logger.info('processing create digest job', data) - - // simulate long running task - await new Promise((resolve) => setTimeout(resolve, 5000)) - - logger.info('digest created') - - return true -} diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 86712a67b..dac1f74d9 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -18,7 +18,6 @@ import { TaskState } from './generated/graphql' import { aiSummarize, AI_SUMMARIZE_JOB_NAME } from './jobs/ai-summarize' import { bulkAction, BULK_ACTION_JOB_NAME } from './jobs/bulk_action' import { callWebhook, CALL_WEBHOOK_JOB_NAME } from './jobs/call_webhook' -import { CREATE_DIGEST_JOB, processCreateDigestJob } from './jobs/create_digest' import { findThumbnail, THUMBNAIL_JOB } from './jobs/find_thumbnail' import { exportAllItems, @@ -66,18 +65,17 @@ import { } from './jobs/email/inbound_emails' export const QUEUE_NAME = 'omnivore-backend-queue' +export const LONG_RUNNING_QUEUE_NAME = 'omnivore-backend-long-running-queue' export const JOB_VERSION = 'v001' -let backendQueue: Queue | undefined -export const getBackendQueue = async (): Promise => { - if (backendQueue) { - await backendQueue.waitUntilReady() - return backendQueue - } +export const getBackendQueue = async ( + name = QUEUE_NAME +): Promise => { if (!redisDataSource.workerRedisClient) { throw new Error('Can not create queues, redis is not initialized') } - backendQueue = new Queue(QUEUE_NAME, { + + const backendQueue = new Queue(name, { connection: redisDataSource.workerRedisClient, defaultJobOptions: { backoff: { @@ -99,8 +97,8 @@ export const getBackendQueue = async (): Promise => { export const createJobId = (jobName: string, userId: string) => `${jobName}_${userId}_${JOB_VERSION}` -export const getJob = async (jobId: string) => { - const queue = await getBackendQueue() +export const getJob = async (jobId: string, queueName?: string) => { + const queue = await getBackendQueue(queueName) if (!queue) { return } @@ -182,8 +180,6 @@ export const createWorker = (connection: ConnectionOptions) => return saveNewsletterJob(job.data) case FORWARD_EMAIL_JOB: return forwardEmailJob(job.data) - case CREATE_DIGEST_JOB: - return processCreateDigestJob(job.data) default: logger.warning(`[queue-processor] unhandled job: ${job.name}`) } diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 7f9ab327b..122097c09 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -1,11 +1,18 @@ import cors from 'cors' import express from 'express' -import { TaskState } from '../generated/graphql' -import { getDigest, setDigest } from '../services/digest' +import { env } from '../env' +import { + createJobId, + getJob, + jobStateToTaskState, + LONG_RUNNING_QUEUE_NAME, +} from '../queue-processor' +import { CREATE_DIGEST_JOB, getDigest } from '../services/digest' import { findActiveUser } from '../services/user' import { analytics } from '../utils/analytics' import { getClaimsByToken, getTokenByRequest } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' +import { enqueueCreateDigest } from '../utils/createTask' import { logger } from '../utils/logger' interface Feedback { @@ -59,24 +66,23 @@ export function digestRouter() { return res.sendStatus(401) } - // check if digest job is running + // check if job is already in queue // if yes then return 202 accepted // else enqueue job - const digest = await getDigest(userId) - if (digest?.jobState === TaskState.Running) { - logger.info(`job is running: ${userId}`) + const jobId = createJobId(CREATE_DIGEST_JOB, userId) + const existingJob = await getJob(jobId, LONG_RUNNING_QUEUE_NAME) + if (existingJob) { + logger.info(`Job already in queue: ${jobId}`) return res.sendStatus(202) } // enqueue job and return job id - const jobId = await setDigest(userId, { - jobState: TaskState.Running, + const result = await enqueueCreateDigest({ + userId, }) // return job id - return res.status(201).send({ - jobId, - }) + return res.status(201).send(result) } catch (error) { logger.error('Error while enqueuing create digest task', error) return res.sendStatus(500) @@ -110,20 +116,25 @@ export function digestRouter() { return res.sendStatus(401) } - // get digest from redis + // get job by user id + const jobId = createJobId(CREATE_DIGEST_JOB, userId) + const job = await getJob(jobId, LONG_RUNNING_QUEUE_NAME) + if (job) { + // if job is in queue then return job state + const jobState = await job.getState() + return res.send({ + jobId: job.id, + jobState: jobStateToTaskState(jobState), + }) + } + + // if job is done and removed then get the digest from redis const digest = await getDigest(userId) if (!digest) { logger.info(`Digest not found: ${userId}`) return res.sendStatus(404) } - if (digest.jobState === TaskState.Running) { - // if job is in queue then return job state - return res.status(202).send({ - jobState: TaskState.Running, - }) - } - // return digest return res.send(digest) } catch (error) { @@ -177,7 +188,11 @@ export function digestRouter() { analytics.capture({ distinctId: userId, event: 'digest_feedback', - properties: feedback, + properties: { + ...feedback, + env: env.server.apiEnv, + userId, + }, }) // return success diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 41dac920f..de2ca2909 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -1,5 +1,15 @@ import { redisDataSource } from '../redis_data_source' +export const CREATE_DIGEST_JOB = 'create-digest' + +export interface CreateDigestJobData { + userId: string +} + +export interface CreateDigestJobResponse { + jobId: string +} + export interface Digest { url?: string title?: string @@ -19,22 +29,3 @@ export const getDigest = async (userId: string): Promise => { const digest = await redisDataSource.redisClient?.get(digestKey(userId)) return digest ? (JSON.parse(digest) as Digest) : null } - -export const setDigest = async ( - userId: string, - digest: Digest -): Promise => { - const key = digestKey(userId) - const result = await redisDataSource.redisClient?.set( - digestKey(userId), - JSON.stringify(digest), - 'EX', - 60 * 60 * 24 // 1 day - ) - - if (result != 'OK') { - throw new Error('Failed to set digest') - } - - return key -} diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 7a8a2d23b..d64471252 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -18,11 +18,6 @@ import { import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize' import { BulkActionData, BULK_ACTION_JOB_NAME } from '../jobs/bulk_action' import { CallWebhookJobData, CALL_WEBHOOK_JOB_NAME } from '../jobs/call_webhook' -import { - CreateDigestJobData, - CreateDigestJobResponse, - CREATE_DIGEST_JOB, -} from '../jobs/create_digest' import { THUMBNAIL_JOB } from '../jobs/find_thumbnail' import { EXPORT_ALL_ITEMS_JOB_NAME } from '../jobs/integration/export_all_items' import { @@ -49,8 +44,18 @@ import { UPDATE_HIGHLIGHT_JOB, UPDATE_LABELS_JOB, } from '../jobs/update_db' -import { createJobId, getBackendQueue, JOB_VERSION } from '../queue-processor' +import { + createJobId, + getBackendQueue, + JOB_VERSION, + LONG_RUNNING_QUEUE_NAME, +} from '../queue-processor' import { redisDataSource } from '../redis_data_source' +import { + CreateDigestJobData, + CreateDigestJobResponse, + CREATE_DIGEST_JOB, +} from '../services/digest' import { signFeatureToken } from '../services/features' import { OmnivoreAuthorizationHeader } from './auth' import { CreateTaskError } from './errors' @@ -86,8 +91,6 @@ export const getJobPriority = (jobName: string): number => { case `${REFRESH_FEED_JOB_NAME}_high`: return 10 case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME: - case CREATE_DIGEST_JOB: - return 20 case `${REFRESH_FEED_JOB_NAME}_low`: case EXPORT_ITEM_JOB_NAME: return 50 @@ -862,7 +865,7 @@ export const enqueueSendEmail = async (jobData: SendEmailJobData) => { export const enqueueCreateDigest = async ( data: CreateDigestJobData ): Promise => { - const queue = await getBackendQueue() + const queue = await getBackendQueue(LONG_RUNNING_QUEUE_NAME) if (!queue) { throw new Error('No queue found') } @@ -873,7 +876,6 @@ export const enqueueCreateDigest = async ( removeOnComplete: true, removeOnFail: true, attempts: 3, - priority: getJobPriority(CREATE_DIGEST_JOB), }) logger.info('create digest job enqueued', { jobId: job.id }) From e30fa13a2e5ef4581827e0bb0623f75d1c8b81a0 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sat, 13 Apr 2024 14:43:22 +0800 Subject: [PATCH 12/33] add env to the posthog event --- packages/api/src/routers/digest_router.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 122097c09..f2c5ec65d 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -191,7 +191,6 @@ export function digestRouter() { properties: { ...feedback, env: env.server.apiEnv, - userId, }, }) From 4e61b360d7316721e95bfc21868269867166fd92 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 15 Apr 2024 17:20:22 +0800 Subject: [PATCH 13/33] Add iOS digest views --- .../App/Views/AI/LibraryDigestView.swift | 267 ++++++++++++++++++ .../App/Views/BottomBars/CustomTabBar.swift | 34 ++- .../App/Views/Home/HomeFeedViewIOS.swift | 10 + .../Sources/App/Views/LibraryTabView.swift | 18 +- .../App/Views/WebReader/WebReader.swift | 2 + .../Views/WebReader/WebReaderContainer.swift | 32 ++- .../Views/WebReader/WebReaderViewModel.swift | 2 + .../Services/Authentication/AuthModels.swift | 2 +- .../DataService/Errors/ServerError.swift | 3 + .../Views/Article/OmnivoreWebView.swift | 17 +- .../Sources/Views/Images/Images.swift | 6 + .../_tab_digest.imageset/Contents.json | 24 ++ .../_tab_digest.imageset/_tab_digest.svg | 18 ++ .../Contents.json | 21 ++ .../_tab_digest_selected.svg | 18 ++ .../Contents.json | 24 ++ .../digest-archive-button.svg | 13 + .../digest-dots-button.imageset/Contents.json | 24 ++ .../digest-dots-button.svg | 13 + .../digest-play-button.imageset/Contents.json | 24 ++ .../digest-play-button.svg | 11 + .../Contents.json | 24 ++ .../digest-trash-button.svg | 15 + .../double_chevron_up.imageset/Contents.json | 21 ++ .../double_chevron_up.svg | 4 + apple/OmnivoreKit/Sources/Views/Theme.swift | 20 ++ 26 files changed, 650 insertions(+), 17 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/_tab_digest.svg create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/_tab_digest_selected.svg create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/digest-archive-button.svg create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/digest-dots-button.svg create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/digest-play-button.svg create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/digest-trash-button.svg create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/Contents.json create mode 100644 apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/double_chevron_up.svg diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift new file mode 100644 index 000000000..e6f4f91d7 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift @@ -0,0 +1,267 @@ +import SwiftUI +import Models +import Services + + +struct DigestItem { + let id: String + let site: String + let siteIcon: URL? + let author: String + let title: String + let summaryText: String + let keyPointsText: String + let highlightsText: String +} + +@available(iOS 17.0, *) +@MainActor +struct LibraryDigestView: View { + let dataService: DataService + // @State private var currentIndex = 0 + @State private var items: [DigestItem] + // @State private var preloadedItems: [Int: String] = [:] + @Environment(\.dismiss) private var dismiss + + // let itemCount = 10 // Number of items to initially load + // let prefetchCount = 2 // Number of items to prefetch + + public init(dataService: DataService) { + self.dataService = dataService + self.items = [ + DigestItem( + id: "1468AFAA-88sdfsdfC-4546-BE02-EACF385288FC", + site: "CNBC.com", + siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), + author: "Kif Leswing", + title: "Apple shares just had their best day since last May", + summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + + " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + + "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + + "electoral strategies. ", + keyPointsText: "Key points from the article:", + highlightsText: "Highlights from the article:" + ), + DigestItem( + id: "1468AFAA-8sdfsdffsdf-4546-BE02-EACF385288FC", + site: "CNBC.com", + siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), + author: "Kif Leswing", + title: "Apple shares just had their best day since last May", + summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + + " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + + "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + + "electoral strategies. ", + keyPointsText: "Key points from the article:", + highlightsText: "Highlights from the article:" + ), + DigestItem( + id: "1468AFAA-882C-asdadfsa85288FC", + site: "CNBC.com", + siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), + author: "Kif Leswing", + title: "Apple shares just had their best day since last May", + summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + + " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + + "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + + "electoral strategies. ", + keyPointsText: "Key points from the article:", + highlightsText: "Highlights from the article:" + ) + ] + // currentIndex = 0 + // _preloadedItems = [Int:String] + } + + var body: some View { + itemBody + } + + @available(iOS 17.0, *) + var itemBody: some View { + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(Array(self.items.enumerated()), id: \.1.id) { idx, item in + PreviewItemView( + viewModel: PreviewItemViewModel(dataService: dataService, item: item, showSwipeHint: idx == 0) + ) + .containerRelativeFrame([.horizontal, .vertical]) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .ignoresSafeArea() + + + // ScrollView(.horizontal, showsIndicators: false) { + // HStack(spacing: 0) { + // ForEach(items.indices, id: \.self) { index in + // if let item = preloadedItems[index] { + // ItemView(content: item) + // .onAppear { + // if index == items.count - prefetchCount { + // fetchItems() + // } + // } + // } + // } + // } + // } + // .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + // fetchItems() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in + // Pause any background tasks if necessary + } + + } + +// private func fetchItems() { +// // Simulate fetching items from an API +// for idx in currentIndex.. Void let tapHandler: () -> Void + let explainHandler: ((String) -> Void)? let scrollPercentHandler: (Int) -> Void let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void let navBarVisibilityUpdater: (Bool) -> Void @@ -51,6 +52,7 @@ struct WebReader: PlatformViewRepresentable { let contentController = WKUserContentController() webView.tapHandler = tapHandler + webView.explainHandler = explainHandler webView.navigationDelegate = context.coordinator webView.configuration.userContentController = contentController webView.configuration.userContentController.removeAllScriptMessageHandlers() diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 1cb4aab3d..5eebee5ee 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -41,6 +41,8 @@ struct WebReaderContainerView: View { @State var displayLinkSheet = false @State var linkToOpen: URL? + @State var showExplainSheet = false + @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @Environment(\.openURL) var openURL @@ -92,6 +94,11 @@ struct WebReaderContainerView: View { } } + private func explainHandler(text: String) { + viewModel.explainText = String(text) + showExplainSheet = true + } + private func handleHighlightAction(message: WKScriptMessage) { guard let messageBody = message.body as? [String: String] else { return } guard let actionID = messageBody["actionID"] else { return } @@ -271,6 +278,13 @@ struct WebReaderContainerView: View { Spacer() #endif +// Button( +// action: { showExplainSheet = true }, +// label: { Image(systemName: "sparkles") } +// ) +// .buttonStyle(.plain) +// .padding(.trailing, 4) + Button( action: { showLabelsModal = true }, label: { @@ -378,6 +392,7 @@ struct WebReaderContainerView: View { #endif }, tapHandler: tapHandler, + explainHandler: explainHandler, scrollPercentHandler: scrollPercentHandler, webViewActionHandler: webViewActionHandler, navBarVisibilityUpdater: { visible in @@ -485,6 +500,20 @@ struct WebReaderContainerView: View { .formSheet(isPresented: $showOpenArchiveSheet) { OpenArchiveTodayView(item: item) } + .formSheet(isPresented: $showExplainSheet) { + ExplainView( + viewModel: ExplainViewModel( + dataService: dataService, + item: self.item, + promptName: "explain-text-001", + extraText: viewModel.explainText + ), + dismissAction: { + viewModel.explainText = nil + showExplainSheet = false + } + ) + } #endif .sheet(isPresented: $showHighlightAnnotationModal) { NavigationView { @@ -633,7 +662,6 @@ struct WebReaderContainerView: View { try? WebViewManager.shared().dispatchEvent(.saveReadPosition) } .onDisappear { - // WebViewManager.shared().loadHTMLString("", baseURL: nil) WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil) } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in @@ -674,7 +702,7 @@ struct WebReaderContainerView: View { shareActionID = UUID() } - func print() { + func printReader() { shareActionID = UUID() } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 1a70626cf..7d5071194 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -21,6 +21,8 @@ struct SafariWebLink: Identifiable { @Published var showOperationToast: Bool = false @Published var operationStatus: OperationStatus = .none + @Published var explainText: String? + func hasOriginalUrl(_ item: Models.LibraryItem) -> Bool { if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host { if host == "omnivore.app" { diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift index 550389c56..fb450b0d6 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift @@ -75,7 +75,7 @@ extension LoginError { return .unauthorized case .unknown: return .unknown - case .pendingEmailVerification: + case .pendingEmailVerification, .stillProcessing: return .pendingEmailVerification } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift b/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift index 601b2e4cf..7e51b2a1a 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift @@ -10,6 +10,7 @@ public enum ServerError: String, Error { case unauthenticated case timeout case unknown + case stillProcessing case pendingEmailVerification } @@ -22,6 +23,8 @@ extension ServerError { case 401?, 403?: self = .unauthenticated return + case 202: + self = .stillProcessing default: break } diff --git a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift index af128dd9b..05dcfb44d 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift @@ -23,6 +23,7 @@ public final class OmnivoreWebView: WKWebView { #endif public var tapHandler: (() -> Void)? + public var explainHandler: ((String) -> Void)? private var currentMenu: ContextMenu = .defaultMenu @@ -298,6 +299,7 @@ public final class OmnivoreWebView: WKWebView { case #selector(removeSelection): return true case #selector(copy(_:)): return true case #selector(setLabels(_:)): return true + case #selector(explainSelection): return true case Selector(("_lookup:")): return (currentMenu == .defaultMenu) case Selector(("_define:")): return (currentMenu == .defaultMenu) @@ -334,6 +336,18 @@ public final class OmnivoreWebView: WKWebView { hideMenu() } + @objc private func explainSelection() { + Task { + let selection = try? await self.evaluateJavaScript("window.getSelection().toString()") + if let selection = selection as? String, let explainHandler = explainHandler { + print("Explaining \(selection)") + explainHandler(selection) + } else { + showInReaderSnackbar("Error getting text to explain") + } + } + } + @objc private func shareSelection() { do { try dispatchEvent(.share) @@ -386,7 +400,8 @@ public final class OmnivoreWebView: WKWebView { return } let highlight = UICommand(title: LocalText.genericHighlight, action: #selector(highlightSelection)) - items = [highlight, annotate] + // let explain = UICommand(title: "Explain", action: #selector(explainSelection)) + items = [highlight, /* explain, */ annotate] } else { let remove = UICommand(title: "Remove", action: #selector(removeSelection)) let setLabels = UICommand(title: LocalText.labelsGeneric, action: #selector(setLabels)) diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.swift b/apple/OmnivoreKit/Sources/Views/Images/Images.swift index f90d34ede..9b684783e 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.swift +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.swift @@ -9,6 +9,9 @@ public extension Image { static var tabFollowing: Image { Image("_tab_following", bundle: .module).renderingMode(.template) } static var tabLibrary: Image { Image("_tab_library", bundle: .module).renderingMode(.template) } + static var tabDigest: Image { Image("_tab_digest", bundle: .module).renderingMode(.template) } + static var tabDigestSelected: Image { Image("_tab_digest_selected", bundle: .module) } + static var tabSearch: Image { Image("_tab_search", bundle: .module).renderingMode(.template) } static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) } static var tabProfile: Image { Image("_tab_profile", bundle: .module).renderingMode(.template) } @@ -49,4 +52,7 @@ public extension Image { static var flairNewsletter: Image { Image("flair-newsletter", bundle: .module) } static var flairPinned: Image { Image("flair-pinned", bundle: .module) } static var flairRecommended: Image { Image("flair-recommended", bundle: .module) } + + static var doubleChevronUp: Image { Image("double_chevron_up", bundle: .module) } + } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/Contents.json new file mode 100644 index 000000000..83aca2101 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "_tab_digest.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/_tab_digest.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/_tab_digest.svg new file mode 100644 index 000000000..ccf49775d --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/_tab_digest.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/Contents.json new file mode 100644 index 000000000..e5fed4197 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "_tab_digest_selected.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/_tab_digest_selected.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/_tab_digest_selected.svg new file mode 100644 index 000000000..dfc6d541d --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/_tab_digest_selected.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/Contents.json new file mode 100644 index 000000000..340a487c3 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "digest-archive-button.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/digest-archive-button.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/digest-archive-button.svg new file mode 100644 index 000000000..ecdbd0d54 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/digest-archive-button.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/Contents.json new file mode 100644 index 000000000..d8d84c76c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "digest-dots-button.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/digest-dots-button.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/digest-dots-button.svg new file mode 100644 index 000000000..41032d9ee --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/digest-dots-button.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/Contents.json new file mode 100644 index 000000000..04686abac --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "digest-play-button.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/digest-play-button.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/digest-play-button.svg new file mode 100644 index 000000000..45875dbb5 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/digest-play-button.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/Contents.json new file mode 100644 index 000000000..fa3f18ec0 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "digest-trash-button.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/digest-trash-button.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/digest-trash-button.svg new file mode 100644 index 000000000..23cef56cb --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/digest-trash-button.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/Contents.json new file mode 100644 index 000000000..6a11721b2 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "double_chevron_up.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/double_chevron_up.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/double_chevron_up.svg new file mode 100644 index 000000000..7ac7be15d --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/double_chevron_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Theme.swift b/apple/OmnivoreKit/Sources/Views/Theme.swift index e4b9147a9..69ebecf70 100644 --- a/apple/OmnivoreKit/Sources/Views/Theme.swift +++ b/apple/OmnivoreKit/Sources/Views/Theme.swift @@ -31,6 +31,22 @@ public enum Theme: String, CaseIterable { } } + public var fgColor: Color { + let prefersHighContrastText = UserDefaults.standard.bool(forKey: UserDefaultKey.prefersHighContrastWebFont.rawValue) + switch self { + case .system: + return Color.isDarkMode ? .white : .black + case .light: + return .black + case .dark: + return Color.white + case .sepia: + return prefersHighContrastText ? Color.black : (Color(hex: "#5F4B32") ?? Color.black) + case .apollo: + return prefersHighContrastText ? Color.white : (Color(hex: "#F3F3F3") ?? Color.white) + } + } + public var toolbarColor: Color { ThemeManager.currentTheme.isDark ? Color.themeDarkWhiteGray : Color.themeMiddleGray } @@ -98,6 +114,10 @@ public enum ThemeManager { currentTheme.bgColor } + public static var currentFgColor: Color { + currentTheme.fgColor + } + public static var currentHighlightColor: Color { currentTheme.highlightColor } From 2b53d7791a785bbc8e0ec212715b29f9c1f221cb Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 15 Apr 2024 20:27:36 +0800 Subject: [PATCH 14/33] Update tab bar to allow opting into digest --- .../App/Views/BottomBars/CustomTabBar.swift | 26 +- .../Sources/App/Views/LibraryTabView.swift | 58 +- .../CoreDataModel.xcdatamodel/contents | 3 +- .../Sources/Models/DataModels/Feature.swift | 10 + .../Services/DataService/GQLSchema.swift | 3429 +++++++++++++++++ .../Mutations/SetRuleMutation.swift | 6 + .../DataService/Queries/ViewerFetcher.swift | 6 +- .../Selections/FeatureSelection.swift | 6 + 8 files changed, 3526 insertions(+), 18 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/Models/DataModels/Feature.swift create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/Selections/FeatureSelection.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift b/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift index eb0bf53d1..67efbb9f8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift +++ b/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift @@ -2,25 +2,31 @@ import Foundation import SwiftUI struct CustomTabBar: View { + let displayTabs: [String] @Binding var selectedTab: String - let hideFollowingTab: Bool var body: some View { HStack(spacing: 0) { - if !hideFollowingTab { + if displayTabs.contains("following") { TabBarButton(key: "following", image: Image.tabFollowing, selectedTab: $selectedTab, selectionColor: Color(hex: "EE8232")) } - TabBarButton(key: "digest", - image: Image.tabDigest, - selectedTab: $selectedTab, - selectedImage: Image.tabDigestSelected) - TabBarButton(key: "inbox", - image: Image.tabLibrary, - selectedTab: $selectedTab) - // TabBarButton(key: "profile", image: Image.tabProfile, selectedTab: $selectedTab) + if displayTabs.contains("digest") { + TabBarButton(key: "digest", + image: Image.tabDigest, + selectedTab: $selectedTab, + selectedImage: Image.tabDigestSelected) + } + if displayTabs.contains("inbox") { + TabBarButton(key: "inbox", + image: Image.tabLibrary, + selectedTab: $selectedTab) + } + if displayTabs.contains("profile") { + TabBarButton(key: "profile", image: Image.tabProfile, selectedTab: $selectedTab) + } } .padding(.top, 10) .padding(.bottom, 10) diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index 0a340ceed..79f9137b7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -21,6 +21,9 @@ struct LibraryTabView: View { @AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false @AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox" + @AppStorage("LibraryTabView::digestEnabled") var digestEnabled = false + @AppStorage("LibraryTabView::hasCheckedForDigestFeature") var hasCheckedForDigestFeature = false + @State var isEditMode: EditMode = .inactive @State var showExpandedAudioPlayer = false @State var presentPushContainer = true @@ -76,6 +79,28 @@ struct LibraryTabView: View { @State var operationStatus: OperationStatus = .none @State var operationMessage: String? + var showDigest: Bool { + if digestEnabled, #available(iOS 17.0, *) { + return true + } + return false + } + + var displayTabs: [String] { + var res = [String]() + if !hideFollowingTab { + res.append("following") + } + if showDigest { + res.append("digest") + } + res.append("inbox") + if !showDigest { + res.append("profile") + } + return res + } + var body: some View { VStack(spacing: 0) { WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) { @@ -116,24 +141,29 @@ struct LibraryTabView: View { }.tag("following") } - if #available(iOS 17.0, *) { + if showDigest, #available(iOS 17.0, *) { NavigationView { LibraryDigestView(dataService: dataService) .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) }.tag("digest") + NavigationView { + HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode) + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(.stack) + }.tag("inbox") } else { NavigationView { HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode) .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) }.tag("inbox") + NavigationView { + ProfileView() + .navigationViewStyle(.stack) + }.tag("profile") } - NavigationView { - ProfileView() - .navigationViewStyle(.stack) - }.tag("profile") } if let audioProperties = audioController.itemAudioProperties { MiniPlayerViewer(itemAudioProperties: audioProperties) @@ -146,7 +176,9 @@ struct LibraryTabView: View { .frame(maxWidth: .infinity) } if isEditMode != .active { - CustomTabBar(selectedTab: $selectedTab, hideFollowingTab: hideFollowingTab) + CustomTabBar( + displayTabs: displayTabs, + selectedTab: $selectedTab) .padding(0) } } @@ -204,5 +236,19 @@ struct LibraryTabView: View { } selectedTab = "inbox" } + .task { + do { + if let viewer = try await dataService.fetchViewer() { + digestEnabled = viewer.digestEnabled ?? false + if !hasCheckedForDigestFeature { + hasCheckedForDigestFeature = true + selectedTab = "digest" + } + } + } catch { + print("ERROR FETCHING VIEWER: ", error) + print("") + } + } } } diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 07612b47e..57cf07b2e 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -137,6 +137,7 @@ + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Feature.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Feature.swift new file mode 100644 index 000000000..2bccfc724 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Feature.swift @@ -0,0 +1,10 @@ + +public struct FeatureInternal { + public let name: String + public let enabled: Bool + + public init(name: String, enabled: Bool) { + self.name = name + self.enabled = enabled + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 49979b626..cd3cf2b2c 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -20,6 +20,137 @@ extension Objects.Subscription: GraphQLWebSocketOperation { // MARK: - Objects enum Objects {} +extension Objects { + struct AddDiscoverFeedError { + let __typename: TypeName = .addDiscoverFeedError + let errorCodes: [String: [Enums.AddDiscoverFeedErrorCode]] + + enum TypeName: String, Codable { + case addDiscoverFeedError = "AddDiscoverFeedError" + } + } +} + +extension Objects.AddDiscoverFeedError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.AddDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.AddDiscoverFeedError { + func errorCodes() throws -> [Enums.AddDiscoverFeedErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddDiscoverFeedError = Selection +} + +extension Objects { + struct AddDiscoverFeedSuccess { + let __typename: TypeName = .addDiscoverFeedSuccess + let feed: [String: Objects.DiscoverFeed] + + enum TypeName: String, Codable { + case addDiscoverFeedSuccess = "AddDiscoverFeedSuccess" + } + } +} + +extension Objects.AddDiscoverFeedSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "feed": + if let value = try container.decode(Objects.DiscoverFeed?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + feed = map["feed"] + } +} + +extension Fields where TypeLock == Objects.AddDiscoverFeedSuccess { + func feed(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "feed", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feed[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddDiscoverFeedSuccess = Selection +} + extension Objects { struct AddPopularReadError { let __typename: TypeName = .addPopularReadError @@ -3921,6 +4052,266 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteAccountSuccess = Selection } +extension Objects { + struct DeleteDiscoverArticleError { + let __typename: TypeName = .deleteDiscoverArticleError + let errorCodes: [String: [Enums.DeleteDiscoverArticleErrorCode]] + + enum TypeName: String, Codable { + case deleteDiscoverArticleError = "DeleteDiscoverArticleError" + } + } +} + +extension Objects.DeleteDiscoverArticleError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteDiscoverArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.DeleteDiscoverArticleError { + func errorCodes() throws -> [Enums.DeleteDiscoverArticleErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverArticleError = Selection +} + +extension Objects { + struct DeleteDiscoverArticleSuccess { + let __typename: TypeName = .deleteDiscoverArticleSuccess + let id: [String: String] + + enum TypeName: String, Codable { + case deleteDiscoverArticleSuccess = "DeleteDiscoverArticleSuccess" + } + } +} + +extension Objects.DeleteDiscoverArticleSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + id = map["id"] + } +} + +extension Fields where TypeLock == Objects.DeleteDiscoverArticleSuccess { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverArticleSuccess = Selection +} + +extension Objects { + struct DeleteDiscoverFeedError { + let __typename: TypeName = .deleteDiscoverFeedError + let errorCodes: [String: [Enums.DeleteDiscoverFeedErrorCode]] + + enum TypeName: String, Codable { + case deleteDiscoverFeedError = "DeleteDiscoverFeedError" + } + } +} + +extension Objects.DeleteDiscoverFeedError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.DeleteDiscoverFeedError { + func errorCodes() throws -> [Enums.DeleteDiscoverFeedErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverFeedError = Selection +} + +extension Objects { + struct DeleteDiscoverFeedSuccess { + let __typename: TypeName = .deleteDiscoverFeedSuccess + let id: [String: String] + + enum TypeName: String, Codable { + case deleteDiscoverFeedSuccess = "DeleteDiscoverFeedSuccess" + } + } +} + +extension Objects.DeleteDiscoverFeedSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + id = map["id"] + } +} + +extension Fields where TypeLock == Objects.DeleteDiscoverFeedSuccess { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverFeedSuccess = Selection +} + extension Objects { struct DeleteFilterError { let __typename: TypeName = .deleteFilterError @@ -5475,6 +5866,867 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeviceTokensSuccess = Selection } +extension Objects { + struct DiscoverFeed { + let __typename: TypeName = .discoverFeed + let description: [String: String] + let id: [String: String] + let image: [String: String] + let link: [String: String] + let title: [String: String] + let type: [String: String] + let visibleName: [String: String] + + enum TypeName: String, Codable { + case discoverFeed = "DiscoverFeed" + } + } +} + +extension Objects.DiscoverFeed: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "image": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "link": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "title": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "type": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "visibleName": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + description = map["description"] + id = map["id"] + image = map["image"] + link = map["link"] + title = map["title"] + type = map["type"] + visibleName = map["visibleName"] + } +} + +extension Fields where TypeLock == Objects.DiscoverFeed { + func description() throws -> String? { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.description[field.alias!] + case .mocking: + return nil + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func image() throws -> String? { + let field = GraphQLField.leaf( + name: "image", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.image[field.alias!] + case .mocking: + return nil + } + } + + func link() throws -> String { + let field = GraphQLField.leaf( + name: "link", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.link[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func title() throws -> String { + let field = GraphQLField.leaf( + name: "title", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.title[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func type() throws -> String { + let field = GraphQLField.leaf( + name: "type", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.type[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func visibleName() throws -> String? { + let field = GraphQLField.leaf( + name: "visibleName", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.visibleName[field.alias!] + case .mocking: + return nil + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeed = Selection +} + +extension Objects { + struct DiscoverFeedArticle { + let __typename: TypeName = .discoverFeedArticle + let author: [String: String] + let description: [String: String] + let feed: [String: String] + let id: [String: String] + let image: [String: String] + let publishedDate: [String: DateTime] + let savedId: [String: String] + let savedLinkUrl: [String: String] + let siteName: [String: String] + let slug: [String: String] + let title: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case discoverFeedArticle = "DiscoverFeedArticle" + } + } +} + +extension Objects.DiscoverFeedArticle: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "author": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feed": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "image": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "publishedDate": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "savedId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "savedLinkUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "siteName": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "slug": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "title": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + author = map["author"] + description = map["description"] + feed = map["feed"] + id = map["id"] + image = map["image"] + publishedDate = map["publishedDate"] + savedId = map["savedId"] + savedLinkUrl = map["savedLinkUrl"] + siteName = map["siteName"] + slug = map["slug"] + title = map["title"] + url = map["url"] + } +} + +extension Fields where TypeLock == Objects.DiscoverFeedArticle { + func author() throws -> String? { + let field = GraphQLField.leaf( + name: "author", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.author[field.alias!] + case .mocking: + return nil + } + } + + func description() throws -> String { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.description[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func feed() throws -> String { + let field = GraphQLField.leaf( + name: "feed", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feed[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func image() throws -> String? { + let field = GraphQLField.leaf( + name: "image", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.image[field.alias!] + case .mocking: + return nil + } + } + + func publishedDate() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "publishedDate", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.publishedDate[field.alias!] + case .mocking: + return nil + } + } + + func savedId() throws -> String? { + let field = GraphQLField.leaf( + name: "savedId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.savedId[field.alias!] + case .mocking: + return nil + } + } + + func savedLinkUrl() throws -> String? { + let field = GraphQLField.leaf( + name: "savedLinkUrl", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.savedLinkUrl[field.alias!] + case .mocking: + return nil + } + } + + func siteName() throws -> String? { + let field = GraphQLField.leaf( + name: "siteName", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.siteName[field.alias!] + case .mocking: + return nil + } + } + + func slug() throws -> String { + let field = GraphQLField.leaf( + name: "slug", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.slug[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func title() throws -> String { + let field = GraphQLField.leaf( + name: "title", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.title[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func url() throws -> String { + let field = GraphQLField.leaf( + name: "url", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.url[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeedArticle = Selection +} + +extension Objects { + struct DiscoverFeedError { + let __typename: TypeName = .discoverFeedError + let errorCodes: [String: [Enums.DiscoverFeedErrorCode]] + + enum TypeName: String, Codable { + case discoverFeedError = "DiscoverFeedError" + } + } +} + +extension Objects.DiscoverFeedError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.DiscoverFeedError { + func errorCodes() throws -> [Enums.DiscoverFeedErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeedError = Selection +} + +extension Objects { + struct DiscoverFeedSuccess { + let __typename: TypeName = .discoverFeedSuccess + let feeds: [String: [Objects.DiscoverFeed?]] + + enum TypeName: String, Codable { + case discoverFeedSuccess = "DiscoverFeedSuccess" + } + } +} + +extension Objects.DiscoverFeedSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "feeds": + if let value = try container.decode([Objects.DiscoverFeed?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + feeds = map["feeds"] + } +} + +extension Fields where TypeLock == Objects.DiscoverFeedSuccess { + func feeds(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "feeds", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feeds[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeedSuccess = Selection +} + +extension Objects { + struct DiscoverTopic { + let __typename: TypeName = .discoverTopic + let description: [String: String] + let name: [String: String] + + enum TypeName: String, Codable { + case discoverTopic = "DiscoverTopic" + } + } +} + +extension Objects.DiscoverTopic: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + description = map["description"] + name = map["name"] + } +} + +extension Fields where TypeLock == Objects.DiscoverTopic { + func description() throws -> String { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.description[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverTopic = Selection +} + +extension Objects { + struct EditDiscoverFeedError { + let __typename: TypeName = .editDiscoverFeedError + let errorCodes: [String: [Enums.EditDiscoverFeedErrorCode]] + + enum TypeName: String, Codable { + case editDiscoverFeedError = "EditDiscoverFeedError" + } + } +} + +extension Objects.EditDiscoverFeedError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.EditDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.EditDiscoverFeedError { + func errorCodes() throws -> [Enums.EditDiscoverFeedErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias EditDiscoverFeedError = Selection +} + +extension Objects { + struct EditDiscoverFeedSuccess { + let __typename: TypeName = .editDiscoverFeedSuccess + let id: [String: String] + + enum TypeName: String, Codable { + case editDiscoverFeedSuccess = "EditDiscoverFeedSuccess" + } + } +} + +extension Objects.EditDiscoverFeedSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + id = map["id"] + } +} + +extension Fields where TypeLock == Objects.EditDiscoverFeedSuccess { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias EditDiscoverFeedSuccess = Selection +} + extension Objects { struct EmptyTrashError { let __typename: TypeName = .emptyTrashError @@ -5602,6 +6854,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias EmptyTrashSuccess = Selection } +extension Objects { + struct ExportToIntegrationError { + let __typename: TypeName = .exportToIntegrationError + let errorCodes: [String: [Enums.ExportToIntegrationErrorCode]] + + enum TypeName: String, Codable { + case exportToIntegrationError = "ExportToIntegrationError" + } + } +} + +extension Objects.ExportToIntegrationError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.ExportToIntegrationErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.ExportToIntegrationError { + func errorCodes() throws -> [Enums.ExportToIntegrationErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ExportToIntegrationError = Selection +} + +extension Objects { + struct ExportToIntegrationSuccess { + let __typename: TypeName = .exportToIntegrationSuccess + let task: [String: Objects.Task] + + enum TypeName: String, Codable { + case exportToIntegrationSuccess = "ExportToIntegrationSuccess" + } + } +} + +extension Objects.ExportToIntegrationSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "task": + if let value = try container.decode(Objects.Task?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + task = map["task"] + } +} + +extension Fields where TypeLock == Objects.ExportToIntegrationSuccess { + func task(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "task", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.task[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ExportToIntegrationSuccess = Selection +} + extension Objects { struct Feature { let __typename: TypeName = .feature @@ -7500,6 +8883,287 @@ extension Selection where TypeLock == Never, Type == Never { typealias GenerateApiKeySuccess = Selection } +extension Objects { + struct GetDiscoverFeedArticleError { + let __typename: TypeName = .getDiscoverFeedArticleError + let errorCodes: [String: [Enums.GetDiscoverFeedArticleErrorCode]] + + enum TypeName: String, Codable { + case getDiscoverFeedArticleError = "GetDiscoverFeedArticleError" + } + } +} + +extension Objects.GetDiscoverFeedArticleError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.GetDiscoverFeedArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.GetDiscoverFeedArticleError { + func errorCodes() throws -> [Enums.GetDiscoverFeedArticleErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverFeedArticleError = Selection +} + +extension Objects { + struct GetDiscoverFeedArticleSuccess { + let __typename: TypeName = .getDiscoverFeedArticleSuccess + let discoverArticles: [String: [Objects.DiscoverFeedArticle?]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case getDiscoverFeedArticleSuccess = "GetDiscoverFeedArticleSuccess" + } + } +} + +extension Objects.GetDiscoverFeedArticleSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "discoverArticles": + if let value = try container.decode([Objects.DiscoverFeedArticle?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + discoverArticles = map["discoverArticles"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Objects.GetDiscoverFeedArticleSuccess { + func discoverArticles(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "discoverArticles", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.discoverArticles[field.alias!]) + case .mocking: + return selection.mock() + } + } + + func pageInfo(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "pageInfo", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageInfo[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverFeedArticleSuccess = Selection +} + +extension Objects { + struct GetDiscoverTopicError { + let __typename: TypeName = .getDiscoverTopicError + let errorCodes: [String: [Enums.GetDiscoverTopicErrorCode]] + + enum TypeName: String, Codable { + case getDiscoverTopicError = "GetDiscoverTopicError" + } + } +} + +extension Objects.GetDiscoverTopicError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.GetDiscoverTopicErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.GetDiscoverTopicError { + func errorCodes() throws -> [Enums.GetDiscoverTopicErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverTopicError = Selection +} + +extension Objects { + struct GetDiscoverTopicSuccess { + let __typename: TypeName = .getDiscoverTopicSuccess + let discoverTopics: [String: [Objects.DiscoverTopic]] + + enum TypeName: String, Codable { + case getDiscoverTopicSuccess = "GetDiscoverTopicSuccess" + } + } +} + +extension Objects.GetDiscoverTopicSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "discoverTopics": + if let value = try container.decode([Objects.DiscoverTopic]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + discoverTopics = map["discoverTopics"] + } +} + +extension Fields where TypeLock == Objects.GetDiscoverTopicSuccess { + func discoverTopics(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "discoverTopics", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.discoverTopics[field.alias!]) + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverTopicSuccess = Selection +} + extension Objects { struct GetFollowersError { let __typename: TypeName = .getFollowersError @@ -9292,6 +10956,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias Integration = Selection } +extension Objects { + struct IntegrationError { + let __typename: TypeName = .integrationError + let errorCodes: [String: [Enums.IntegrationErrorCode]] + + enum TypeName: String, Codable { + case integrationError = "IntegrationError" + } + } +} + +extension Objects.IntegrationError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.IntegrationErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.IntegrationError { + func errorCodes() throws -> [Enums.IntegrationErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias IntegrationError = Selection +} + +extension Objects { + struct IntegrationSuccess { + let __typename: TypeName = .integrationSuccess + let integration: [String: Objects.Integration] + + enum TypeName: String, Codable { + case integrationSuccess = "IntegrationSuccess" + } + } +} + +extension Objects.IntegrationSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "integration": + if let value = try container.decode(Objects.Integration?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + integration = map["integration"] + } +} + +extension Fields where TypeLock == Objects.IntegrationSuccess { + func integration(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "integration", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.integration[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias IntegrationSuccess = Selection +} + extension Objects { struct IntegrationsError { let __typename: TypeName = .integrationsError @@ -11440,6 +13235,7 @@ extension Selection where TypeLock == Never, Type == Never { extension Objects { struct Mutation { let __typename: TypeName = .mutation + let addDiscoverFeed: [String: Unions.AddDiscoverFeedResult] let addPopularRead: [String: Unions.AddPopularReadResult] let bulkAction: [String: Unions.BulkActionResult] let createArticle: [String: Unions.CreateArticleResult] @@ -11449,6 +13245,8 @@ extension Objects { let createLabel: [String: Unions.CreateLabelResult] let createNewsletterEmail: [String: Unions.CreateNewsletterEmailResult] let deleteAccount: [String: Unions.DeleteAccountResult] + let deleteDiscoverArticle: [String: Unions.DeleteDiscoverArticleResult] + let deleteDiscoverFeed: [String: Unions.DeleteDiscoverFeedResult] let deleteFilter: [String: Unions.DeleteFilterResult] let deleteHighlight: [String: Unions.DeleteHighlightResult] let deleteIntegration: [String: Unions.DeleteIntegrationResult] @@ -11456,7 +13254,9 @@ extension Objects { let deleteNewsletterEmail: [String: Unions.DeleteNewsletterEmailResult] let deleteRule: [String: Unions.DeleteRuleResult] let deleteWebhook: [String: Unions.DeleteWebhookResult] + let editDiscoverFeed: [String: Unions.EditDiscoverFeedResult] let emptyTrash: [String: Unions.EmptyTrashResult] + let exportToIntegration: [String: Unions.ExportToIntegrationResult] let fetchContent: [String: Unions.FetchContentResult] let generateApiKey: [String: Unions.GenerateApiKeyResult] let googleLogin: [String: Unions.LoginResult] @@ -11476,6 +13276,7 @@ extension Objects { let reportItem: [String: Objects.ReportItemResult] let revokeApiKey: [String: Unions.RevokeApiKeyResult] let saveArticleReadingProgress: [String: Unions.SaveArticleReadingProgressResult] + let saveDiscoverArticle: [String: Unions.SaveDiscoverArticleResult] let saveFile: [String: Unions.SaveResult] let saveFilter: [String: Unions.SaveFilterResult] let savePage: [String: Unions.SaveResult] @@ -11522,6 +13323,10 @@ extension Objects.Mutation: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { + case "addDiscoverFeed": + if let value = try container.decode(Unions.AddDiscoverFeedResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "addPopularRead": if let value = try container.decode(Unions.AddPopularReadResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11558,6 +13363,14 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteAccountResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "deleteDiscoverArticle": + if let value = try container.decode(Unions.DeleteDiscoverArticleResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "deleteDiscoverFeed": + if let value = try container.decode(Unions.DeleteDiscoverFeedResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "deleteFilter": if let value = try container.decode(Unions.DeleteFilterResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11586,10 +13399,18 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteWebhookResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "editDiscoverFeed": + if let value = try container.decode(Unions.EditDiscoverFeedResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "emptyTrash": if let value = try container.decode(Unions.EmptyTrashResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "exportToIntegration": + if let value = try container.decode(Unions.ExportToIntegrationResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "fetchContent": if let value = try container.decode(Unions.FetchContentResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11666,6 +13487,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.SaveArticleReadingProgressResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "saveDiscoverArticle": + if let value = try container.decode(Unions.SaveDiscoverArticleResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "saveFile": if let value = try container.decode(Unions.SaveResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11784,6 +13609,7 @@ extension Objects.Mutation: Decodable { } } + addDiscoverFeed = map["addDiscoverFeed"] addPopularRead = map["addPopularRead"] bulkAction = map["bulkAction"] createArticle = map["createArticle"] @@ -11793,6 +13619,8 @@ extension Objects.Mutation: Decodable { createLabel = map["createLabel"] createNewsletterEmail = map["createNewsletterEmail"] deleteAccount = map["deleteAccount"] + deleteDiscoverArticle = map["deleteDiscoverArticle"] + deleteDiscoverFeed = map["deleteDiscoverFeed"] deleteFilter = map["deleteFilter"] deleteHighlight = map["deleteHighlight"] deleteIntegration = map["deleteIntegration"] @@ -11800,7 +13628,9 @@ extension Objects.Mutation: Decodable { deleteNewsletterEmail = map["deleteNewsletterEmail"] deleteRule = map["deleteRule"] deleteWebhook = map["deleteWebhook"] + editDiscoverFeed = map["editDiscoverFeed"] emptyTrash = map["emptyTrash"] + exportToIntegration = map["exportToIntegration"] fetchContent = map["fetchContent"] generateApiKey = map["generateApiKey"] googleLogin = map["googleLogin"] @@ -11820,6 +13650,7 @@ extension Objects.Mutation: Decodable { reportItem = map["reportItem"] revokeApiKey = map["revokeApiKey"] saveArticleReadingProgress = map["saveArticleReadingProgress"] + saveDiscoverArticle = map["saveDiscoverArticle"] saveFile = map["saveFile"] saveFilter = map["saveFilter"] savePage = map["savePage"] @@ -11851,6 +13682,25 @@ extension Objects.Mutation: Decodable { } extension Fields where TypeLock == Objects.Mutation { + func addDiscoverFeed(input: InputObjects.AddDiscoverFeedInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "addDiscoverFeed", + arguments: [Argument(name: "input", type: "AddDiscoverFeedInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.addDiscoverFeed[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func addPopularRead(name: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "addPopularRead", @@ -12022,6 +13872,44 @@ extension Fields where TypeLock == Objects.Mutation { } } + func deleteDiscoverArticle(input: InputObjects.DeleteDiscoverArticleInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "deleteDiscoverArticle", + arguments: [Argument(name: "input", type: "DeleteDiscoverArticleInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.deleteDiscoverArticle[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func deleteDiscoverFeed(input: InputObjects.DeleteDiscoverFeedInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "deleteDiscoverFeed", + arguments: [Argument(name: "input", type: "DeleteDiscoverFeedInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.deleteDiscoverFeed[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func deleteFilter(id: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "deleteFilter", @@ -12155,6 +14043,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func editDiscoverFeed(input: InputObjects.EditDiscoverFeedInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "editDiscoverFeed", + arguments: [Argument(name: "input", type: "EditDiscoverFeedInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.editDiscoverFeed[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func emptyTrash(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "emptyTrash", @@ -12174,6 +14081,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func exportToIntegration(integrationId: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "exportToIntegration", + arguments: [Argument(name: "integrationId", type: "ID!", value: integrationId)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.exportToIntegration[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func fetchContent(id: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "fetchContent", @@ -12535,6 +14461,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func saveDiscoverArticle(input: InputObjects.SaveDiscoverArticleInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "saveDiscoverArticle", + arguments: [Argument(name: "input", type: "SaveDiscoverArticleInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.saveDiscoverArticle[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func saveFile(input: InputObjects.SaveFileInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "saveFile", @@ -14218,11 +16163,15 @@ extension Objects { let article: [String: Unions.ArticleResult] let articleSavingRequest: [String: Unions.ArticleSavingRequestResult] let deviceTokens: [String: Unions.DeviceTokensResult] + let discoverFeeds: [String: Unions.DiscoverFeedResult] + let discoverTopics: [String: Unions.GetDiscoverTopicResults] let feeds: [String: Unions.FeedsResult] let filters: [String: Unions.FiltersResult] + let getDiscoverFeedArticles: [String: Unions.GetDiscoverFeedArticleResults] let getUserPersonalization: [String: Unions.GetUserPersonalizationResult] let groups: [String: Unions.GroupsResult] let hello: [String: String] + let integration: [String: Unions.IntegrationResult] let integrations: [String: Unions.IntegrationsResult] let labels: [String: Unions.LabelsResult] let me: [String: Objects.User] @@ -14276,6 +16225,14 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.DeviceTokensResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "discoverFeeds": + if let value = try container.decode(Unions.DiscoverFeedResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "discoverTopics": + if let value = try container.decode(Unions.GetDiscoverTopicResults?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "feeds": if let value = try container.decode(Unions.FeedsResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -14284,6 +16241,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.FiltersResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "getDiscoverFeedArticles": + if let value = try container.decode(Unions.GetDiscoverFeedArticleResults?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "getUserPersonalization": if let value = try container.decode(Unions.GetUserPersonalizationResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -14296,6 +16257,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "integration": + if let value = try container.decode(Unions.IntegrationResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "integrations": if let value = try container.decode(Unions.IntegrationsResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -14382,11 +16347,15 @@ extension Objects.Query: Decodable { article = map["article"] articleSavingRequest = map["articleSavingRequest"] deviceTokens = map["deviceTokens"] + discoverFeeds = map["discoverFeeds"] + discoverTopics = map["discoverTopics"] feeds = map["feeds"] filters = map["filters"] + getDiscoverFeedArticles = map["getDiscoverFeedArticles"] getUserPersonalization = map["getUserPersonalization"] groups = map["groups"] hello = map["hello"] + integration = map["integration"] integrations = map["integrations"] labels = map["labels"] me = map["me"] @@ -14485,6 +16454,44 @@ extension Fields where TypeLock == Objects.Query { } } + func discoverFeeds(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "discoverFeeds", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.discoverFeeds[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func discoverTopics(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "discoverTopics", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.discoverTopics[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func feeds(input: InputObjects.FeedsInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "feeds", @@ -14523,6 +16530,25 @@ extension Fields where TypeLock == Objects.Query { } } + func getDiscoverFeedArticles(after: OptionalArgument = .absent(), discoverTopicId: String, feedId: OptionalArgument = .absent(), first: OptionalArgument = .absent(), selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "getDiscoverFeedArticles", + arguments: [Argument(name: "after", type: "String", value: after), Argument(name: "discoverTopicId", type: "String!", value: discoverTopicId), Argument(name: "feedId", type: "ID", value: feedId), Argument(name: "first", type: "Int", value: first)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.getDiscoverFeedArticles[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func getUserPersonalization(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "getUserPersonalization", @@ -14576,6 +16602,25 @@ extension Fields where TypeLock == Objects.Query { } } + func integration(name: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "integration", + arguments: [Argument(name: "name", type: "String!", value: name)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.integration[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func integrations(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "integrations", @@ -17133,6 +19178,7 @@ extension Objects { let createdAt: [String: DateTime] let enabled: [String: Bool] let eventTypes: [String: [Enums.RuleEventType]] + let failedAt: [String: DateTime] let filter: [String: String] let id: [String: String] let name: [String: String] @@ -17172,6 +19218,10 @@ extension Objects.Rule: Decodable { if let value = try container.decode([Enums.RuleEventType]?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "failedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "filter": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -17202,6 +19252,7 @@ extension Objects.Rule: Decodable { createdAt = map["createdAt"] enabled = map["enabled"] eventTypes = map["eventTypes"] + failedAt = map["failedAt"] filter = map["filter"] id = map["id"] name = map["name"] @@ -17283,6 +19334,21 @@ extension Fields where TypeLock == Objects.Rule { } } + func failedAt() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "failedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.failedAt[field.alias!] + case .mocking: + return nil + } + } + func filter() throws -> String { let field = GraphQLField.leaf( name: "filter", @@ -17708,6 +19774,160 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveArticleReadingProgressSuccess = Selection } +extension Objects { + struct SaveDiscoverArticleError { + let __typename: TypeName = .saveDiscoverArticleError + let errorCodes: [String: [Enums.SaveDiscoverArticleErrorCode]] + + enum TypeName: String, Codable { + case saveDiscoverArticleError = "SaveDiscoverArticleError" + } + } +} + +extension Objects.SaveDiscoverArticleError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SaveDiscoverArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.SaveDiscoverArticleError { + func errorCodes() throws -> [Enums.SaveDiscoverArticleErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveDiscoverArticleError = Selection +} + +extension Objects { + struct SaveDiscoverArticleSuccess { + let __typename: TypeName = .saveDiscoverArticleSuccess + let saveId: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case saveDiscoverArticleSuccess = "SaveDiscoverArticleSuccess" + } + } +} + +extension Objects.SaveDiscoverArticleSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "saveId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + saveId = map["saveId"] + url = map["url"] + } +} + +extension Fields where TypeLock == Objects.SaveDiscoverArticleSuccess { + func saveId() throws -> String { + let field = GraphQLField.leaf( + name: "saveId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.saveId[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func url() throws -> String { + let field = GraphQLField.leaf( + name: "url", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.url[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveDiscoverArticleSuccess = Selection +} + extension Objects { struct SaveError { let __typename: TypeName = .saveError @@ -22200,6 +24420,227 @@ extension Selection where TypeLock == Never, Type == Never { typealias SyncUpdatedItemEdge = Selection } +extension Objects { + struct Task { + let __typename: TypeName = .task + let cancellable: [String: Bool] + let createdAt: [String: DateTime] + let failedReason: [String: String] + let id: [String: String] + let name: [String: String] + let progress: [String: Double] + let runningTime: [String: Int] + let state: [String: Enums.TaskState] + + enum TypeName: String, Codable { + case task = "Task" + } + } +} + +extension Objects.Task: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "cancellable": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "failedReason": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "progress": + if let value = try container.decode(Double?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "runningTime": + if let value = try container.decode(Int?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "state": + if let value = try container.decode(Enums.TaskState?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + cancellable = map["cancellable"] + createdAt = map["createdAt"] + failedReason = map["failedReason"] + id = map["id"] + name = map["name"] + progress = map["progress"] + runningTime = map["runningTime"] + state = map["state"] + } +} + +extension Fields where TypeLock == Objects.Task { + func cancellable() throws -> Bool? { + let field = GraphQLField.leaf( + name: "cancellable", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.cancellable[field.alias!] + case .mocking: + return nil + } + } + + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func failedReason() throws -> String? { + let field = GraphQLField.leaf( + name: "failedReason", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.failedReason[field.alias!] + case .mocking: + return nil + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func progress() throws -> Double? { + let field = GraphQLField.leaf( + name: "progress", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.progress[field.alias!] + case .mocking: + return nil + } + } + + func runningTime() throws -> Int? { + let field = GraphQLField.leaf( + name: "runningTime", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.runningTime[field.alias!] + case .mocking: + return nil + } + } + + func state() throws -> Enums.TaskState { + let field = GraphQLField.leaf( + name: "state", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.state[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.TaskState.allCases.first! + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias Task = Selection +} + extension Objects { struct TypeaheadSearchError { let __typename: TypeName = .typeaheadSearchError @@ -24845,6 +27286,7 @@ extension Objects { struct User { let __typename: TypeName = .user let email: [String: String] + let featureList: [String: [Objects.Feature]] let features: [String: [String?]] let followersCount: [String: Int] let friendsCount: [String: Int] @@ -24884,6 +27326,10 @@ extension Objects.User: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "featureList": + if let value = try container.decode([Objects.Feature]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "features": if let value = try container.decode([String?]?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -24959,6 +27405,7 @@ extension Objects.User: Decodable { } email = map["email"] + featureList = map["featureList"] features = map["features"] followersCount = map["followersCount"] friendsCount = map["friendsCount"] @@ -24994,6 +27441,22 @@ extension Fields where TypeLock == Objects.User { } } + func featureList(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "featureList", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.featureList[field.alias!]) + case .mocking: + return selection.mock() + } + } + func features() throws -> [String?]? { let field = GraphQLField.leaf( name: "features", @@ -26308,6 +28771,80 @@ enum Interfaces {} // MARK: - Unions enum Unions {} +extension Unions { + struct AddDiscoverFeedResult { + let __typename: TypeName + let errorCodes: [String: [Enums.AddDiscoverFeedErrorCode]] + let feed: [String: Objects.DiscoverFeed] + + enum TypeName: String, Codable { + case addDiscoverFeedError = "AddDiscoverFeedError" + case addDiscoverFeedSuccess = "AddDiscoverFeedSuccess" + } + } +} + +extension Unions.AddDiscoverFeedResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.AddDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feed": + if let value = try container.decode(Objects.DiscoverFeed?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + feed = map["feed"] + } +} + +extension Fields where TypeLock == Unions.AddDiscoverFeedResult { + func on(addDiscoverFeedError: Selection, addDiscoverFeedSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "AddDiscoverFeedError", selection: addDiscoverFeedError.selection), GraphQLField.fragment(type: "AddDiscoverFeedSuccess", selection: addDiscoverFeedSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .addDiscoverFeedError: + let data = Objects.AddDiscoverFeedError(errorCodes: data.errorCodes) + return try addDiscoverFeedError.decode(data: data) + case .addDiscoverFeedSuccess: + let data = Objects.AddDiscoverFeedSuccess(feed: data.feed) + return try addDiscoverFeedSuccess.decode(data: data) + } + case .mocking: + return addDiscoverFeedError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddDiscoverFeedResult = Selection +} + extension Unions { struct AddPopularReadResult { let __typename: TypeName @@ -27590,6 +30127,154 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteAccountResult = Selection } +extension Unions { + struct DeleteDiscoverArticleResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DeleteDiscoverArticleErrorCode]] + let id: [String: String] + + enum TypeName: String, Codable { + case deleteDiscoverArticleError = "DeleteDiscoverArticleError" + case deleteDiscoverArticleSuccess = "DeleteDiscoverArticleSuccess" + } + } +} + +extension Unions.DeleteDiscoverArticleResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteDiscoverArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + id = map["id"] + } +} + +extension Fields where TypeLock == Unions.DeleteDiscoverArticleResult { + func on(deleteDiscoverArticleError: Selection, deleteDiscoverArticleSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DeleteDiscoverArticleError", selection: deleteDiscoverArticleError.selection), GraphQLField.fragment(type: "DeleteDiscoverArticleSuccess", selection: deleteDiscoverArticleSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .deleteDiscoverArticleError: + let data = Objects.DeleteDiscoverArticleError(errorCodes: data.errorCodes) + return try deleteDiscoverArticleError.decode(data: data) + case .deleteDiscoverArticleSuccess: + let data = Objects.DeleteDiscoverArticleSuccess(id: data.id) + return try deleteDiscoverArticleSuccess.decode(data: data) + } + case .mocking: + return deleteDiscoverArticleError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverArticleResult = Selection +} + +extension Unions { + struct DeleteDiscoverFeedResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DeleteDiscoverFeedErrorCode]] + let id: [String: String] + + enum TypeName: String, Codable { + case deleteDiscoverFeedError = "DeleteDiscoverFeedError" + case deleteDiscoverFeedSuccess = "DeleteDiscoverFeedSuccess" + } + } +} + +extension Unions.DeleteDiscoverFeedResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + id = map["id"] + } +} + +extension Fields where TypeLock == Unions.DeleteDiscoverFeedResult { + func on(deleteDiscoverFeedError: Selection, deleteDiscoverFeedSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DeleteDiscoverFeedError", selection: deleteDiscoverFeedError.selection), GraphQLField.fragment(type: "DeleteDiscoverFeedSuccess", selection: deleteDiscoverFeedSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .deleteDiscoverFeedError: + let data = Objects.DeleteDiscoverFeedError(errorCodes: data.errorCodes) + return try deleteDiscoverFeedError.decode(data: data) + case .deleteDiscoverFeedSuccess: + let data = Objects.DeleteDiscoverFeedSuccess(id: data.id) + return try deleteDiscoverFeedSuccess.decode(data: data) + } + case .mocking: + return deleteDiscoverFeedError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverFeedResult = Selection +} + extension Unions { struct DeleteFilterResult { let __typename: TypeName @@ -28404,6 +31089,154 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeviceTokensResult = Selection } +extension Unions { + struct DiscoverFeedResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DiscoverFeedErrorCode]] + let feeds: [String: [Objects.DiscoverFeed?]] + + enum TypeName: String, Codable { + case discoverFeedError = "DiscoverFeedError" + case discoverFeedSuccess = "DiscoverFeedSuccess" + } + } +} + +extension Unions.DiscoverFeedResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feeds": + if let value = try container.decode([Objects.DiscoverFeed?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + feeds = map["feeds"] + } +} + +extension Fields where TypeLock == Unions.DiscoverFeedResult { + func on(discoverFeedError: Selection, discoverFeedSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DiscoverFeedError", selection: discoverFeedError.selection), GraphQLField.fragment(type: "DiscoverFeedSuccess", selection: discoverFeedSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .discoverFeedError: + let data = Objects.DiscoverFeedError(errorCodes: data.errorCodes) + return try discoverFeedError.decode(data: data) + case .discoverFeedSuccess: + let data = Objects.DiscoverFeedSuccess(feeds: data.feeds) + return try discoverFeedSuccess.decode(data: data) + } + case .mocking: + return discoverFeedError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeedResult = Selection +} + +extension Unions { + struct EditDiscoverFeedResult { + let __typename: TypeName + let errorCodes: [String: [Enums.EditDiscoverFeedErrorCode]] + let id: [String: String] + + enum TypeName: String, Codable { + case editDiscoverFeedError = "EditDiscoverFeedError" + case editDiscoverFeedSuccess = "EditDiscoverFeedSuccess" + } + } +} + +extension Unions.EditDiscoverFeedResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.EditDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + id = map["id"] + } +} + +extension Fields where TypeLock == Unions.EditDiscoverFeedResult { + func on(editDiscoverFeedError: Selection, editDiscoverFeedSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "EditDiscoverFeedError", selection: editDiscoverFeedError.selection), GraphQLField.fragment(type: "EditDiscoverFeedSuccess", selection: editDiscoverFeedSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .editDiscoverFeedError: + let data = Objects.EditDiscoverFeedError(errorCodes: data.errorCodes) + return try editDiscoverFeedError.decode(data: data) + case .editDiscoverFeedSuccess: + let data = Objects.EditDiscoverFeedSuccess(id: data.id) + return try editDiscoverFeedSuccess.decode(data: data) + } + case .mocking: + return editDiscoverFeedError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias EditDiscoverFeedResult = Selection +} + extension Unions { struct EmptyTrashResult { let __typename: TypeName @@ -28478,6 +31311,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias EmptyTrashResult = Selection } +extension Unions { + struct ExportToIntegrationResult { + let __typename: TypeName + let errorCodes: [String: [Enums.ExportToIntegrationErrorCode]] + let task: [String: Objects.Task] + + enum TypeName: String, Codable { + case exportToIntegrationError = "ExportToIntegrationError" + case exportToIntegrationSuccess = "ExportToIntegrationSuccess" + } + } +} + +extension Unions.ExportToIntegrationResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.ExportToIntegrationErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "task": + if let value = try container.decode(Objects.Task?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + task = map["task"] + } +} + +extension Fields where TypeLock == Unions.ExportToIntegrationResult { + func on(exportToIntegrationError: Selection, exportToIntegrationSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "ExportToIntegrationError", selection: exportToIntegrationError.selection), GraphQLField.fragment(type: "ExportToIntegrationSuccess", selection: exportToIntegrationSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .exportToIntegrationError: + let data = Objects.ExportToIntegrationError(errorCodes: data.errorCodes) + return try exportToIntegrationError.decode(data: data) + case .exportToIntegrationSuccess: + let data = Objects.ExportToIntegrationSuccess(task: data.task) + return try exportToIntegrationSuccess.decode(data: data) + } + case .mocking: + return exportToIntegrationError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ExportToIntegrationResult = Selection +} + extension Unions { struct FeedArticlesResult { let __typename: TypeName @@ -28860,6 +31767,160 @@ extension Selection where TypeLock == Never, Type == Never { typealias GenerateApiKeyResult = Selection } +extension Unions { + struct GetDiscoverFeedArticleResults { + let __typename: TypeName + let discoverArticles: [String: [Objects.DiscoverFeedArticle?]] + let errorCodes: [String: [Enums.GetDiscoverFeedArticleErrorCode]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case getDiscoverFeedArticleError = "GetDiscoverFeedArticleError" + case getDiscoverFeedArticleSuccess = "GetDiscoverFeedArticleSuccess" + } + } +} + +extension Unions.GetDiscoverFeedArticleResults: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "discoverArticles": + if let value = try container.decode([Objects.DiscoverFeedArticle?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.GetDiscoverFeedArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + discoverArticles = map["discoverArticles"] + errorCodes = map["errorCodes"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Unions.GetDiscoverFeedArticleResults { + func on(getDiscoverFeedArticleError: Selection, getDiscoverFeedArticleSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GetDiscoverFeedArticleError", selection: getDiscoverFeedArticleError.selection), GraphQLField.fragment(type: "GetDiscoverFeedArticleSuccess", selection: getDiscoverFeedArticleSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .getDiscoverFeedArticleError: + let data = Objects.GetDiscoverFeedArticleError(errorCodes: data.errorCodes) + return try getDiscoverFeedArticleError.decode(data: data) + case .getDiscoverFeedArticleSuccess: + let data = Objects.GetDiscoverFeedArticleSuccess(discoverArticles: data.discoverArticles, pageInfo: data.pageInfo) + return try getDiscoverFeedArticleSuccess.decode(data: data) + } + case .mocking: + return getDiscoverFeedArticleError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverFeedArticleResults = Selection +} + +extension Unions { + struct GetDiscoverTopicResults { + let __typename: TypeName + let discoverTopics: [String: [Objects.DiscoverTopic]] + let errorCodes: [String: [Enums.GetDiscoverTopicErrorCode]] + + enum TypeName: String, Codable { + case getDiscoverTopicError = "GetDiscoverTopicError" + case getDiscoverTopicSuccess = "GetDiscoverTopicSuccess" + } + } +} + +extension Unions.GetDiscoverTopicResults: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "discoverTopics": + if let value = try container.decode([Objects.DiscoverTopic]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.GetDiscoverTopicErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + discoverTopics = map["discoverTopics"] + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Unions.GetDiscoverTopicResults { + func on(getDiscoverTopicError: Selection, getDiscoverTopicSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GetDiscoverTopicError", selection: getDiscoverTopicError.selection), GraphQLField.fragment(type: "GetDiscoverTopicSuccess", selection: getDiscoverTopicSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .getDiscoverTopicError: + let data = Objects.GetDiscoverTopicError(errorCodes: data.errorCodes) + return try getDiscoverTopicError.decode(data: data) + case .getDiscoverTopicSuccess: + let data = Objects.GetDiscoverTopicSuccess(discoverTopics: data.discoverTopics) + return try getDiscoverTopicSuccess.decode(data: data) + } + case .mocking: + return getDiscoverTopicError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverTopicResults = Selection +} + extension Unions { struct GetFollowersResult { let __typename: TypeName @@ -29304,6 +32365,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias ImportFromIntegrationResult = Selection } +extension Unions { + struct IntegrationResult { + let __typename: TypeName + let errorCodes: [String: [Enums.IntegrationErrorCode]] + let integration: [String: Objects.Integration] + + enum TypeName: String, Codable { + case integrationError = "IntegrationError" + case integrationSuccess = "IntegrationSuccess" + } + } +} + +extension Unions.IntegrationResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.IntegrationErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "integration": + if let value = try container.decode(Objects.Integration?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + integration = map["integration"] + } +} + +extension Fields where TypeLock == Unions.IntegrationResult { + func on(integrationError: Selection, integrationSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "IntegrationError", selection: integrationError.selection), GraphQLField.fragment(type: "IntegrationSuccess", selection: integrationSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .integrationError: + let data = Objects.IntegrationError(errorCodes: data.errorCodes) + return try integrationError.decode(data: data) + case .integrationSuccess: + let data = Objects.IntegrationSuccess(integration: data.integration) + return try integrationSuccess.decode(data: data) + } + case .mocking: + return integrationError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias IntegrationResult = Selection +} + extension Unions { struct IntegrationsResult { let __typename: TypeName @@ -30864,6 +33999,86 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveArticleReadingProgressResult = Selection } +extension Unions { + struct SaveDiscoverArticleResult { + let __typename: TypeName + let errorCodes: [String: [Enums.SaveDiscoverArticleErrorCode]] + let saveId: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case saveDiscoverArticleError = "SaveDiscoverArticleError" + case saveDiscoverArticleSuccess = "SaveDiscoverArticleSuccess" + } + } +} + +extension Unions.SaveDiscoverArticleResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SaveDiscoverArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "saveId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + saveId = map["saveId"] + url = map["url"] + } +} + +extension Fields where TypeLock == Unions.SaveDiscoverArticleResult { + func on(saveDiscoverArticleError: Selection, saveDiscoverArticleSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SaveDiscoverArticleError", selection: saveDiscoverArticleError.selection), GraphQLField.fragment(type: "SaveDiscoverArticleSuccess", selection: saveDiscoverArticleSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .saveDiscoverArticleError: + let data = Objects.SaveDiscoverArticleError(errorCodes: data.errorCodes) + return try saveDiscoverArticleError.decode(data: data) + case .saveDiscoverArticleSuccess: + let data = Objects.SaveDiscoverArticleSuccess(saveId: data.saveId, url: data.url) + return try saveDiscoverArticleSuccess.decode(data: data) + } + case .mocking: + return saveDiscoverArticleError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveDiscoverArticleResult = Selection +} + extension Unions { struct SaveFilterResult { let __typename: TypeName @@ -33967,6 +37182,19 @@ extension Selection where TypeLock == Never, Type == Never { // MARK: - Enums enum Enums {} +extension Enums { + /// AddDiscoverFeedErrorCode + enum AddDiscoverFeedErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case conflict = "CONFLICT" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// AddPopularReadErrorCode enum AddPopularReadErrorCode: String, CaseIterable, Codable { @@ -34201,6 +37429,30 @@ extension Enums { } } +extension Enums { + /// DeleteDiscoverArticleErrorCode + enum DeleteDiscoverArticleErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + +extension Enums { + /// DeleteDiscoverFeedErrorCode + enum DeleteDiscoverFeedErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case conflict = "CONFLICT" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// DeleteFilterErrorCode enum DeleteFilterErrorCode: String, CaseIterable, Codable { @@ -34331,6 +37583,26 @@ extension Enums { } } +extension Enums { + /// DiscoverFeedErrorCode + enum DiscoverFeedErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + +extension Enums { + /// EditDiscoverFeedErrorCode + enum EditDiscoverFeedErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// EmptyTrashErrorCode enum EmptyTrashErrorCode: String, CaseIterable, Codable { @@ -34338,6 +37610,15 @@ extension Enums { } } +extension Enums { + /// ExportToIntegrationErrorCode + enum ExportToIntegrationErrorCode: String, CaseIterable, Codable { + case failedToCreateTask = "FAILED_TO_CREATE_TASK" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// FeedArticlesErrorCode enum FeedArticlesErrorCode: String, CaseIterable, Codable { @@ -34394,6 +37675,24 @@ extension Enums { } } +extension Enums { + /// GetDiscoverFeedArticleErrorCode + enum GetDiscoverFeedArticleErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + +extension Enums { + /// GetDiscoverTopicErrorCode + enum GetDiscoverTopicErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// GetFollowersErrorCode enum GetFollowersErrorCode: String, CaseIterable, Codable { @@ -34457,6 +37756,13 @@ extension Enums { } } +extension Enums { + /// IntegrationErrorCode + enum IntegrationErrorCode: String, CaseIterable, Codable { + case notFound = "NOT_FOUND" + } +} + extension Enums { /// IntegrationType enum IntegrationType: String, CaseIterable, Codable { @@ -34744,15 +38050,25 @@ extension Enums { case delete = "DELETE" + case export = "EXPORT" + case markAsRead = "MARK_AS_READ" case sendNotification = "SEND_NOTIFICATION" + + case webhook = "WEBHOOK" } } extension Enums { /// RuleEventType enum RuleEventType: String, CaseIterable, Codable { + case highlightCreated = "HIGHLIGHT_CREATED" + + case highlightUpdated = "HIGHLIGHT_UPDATED" + + case labelCreated = "LABEL_CREATED" + case pageCreated = "PAGE_CREATED" case pageUpdated = "PAGE_UPDATED" @@ -34779,6 +38095,17 @@ extension Enums { } } +extension Enums { + /// SaveDiscoverArticleErrorCode + enum SaveDiscoverArticleErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// SaveErrorCode enum SaveErrorCode: String, CaseIterable, Codable { @@ -35045,6 +38372,21 @@ extension Enums { } } +extension Enums { + /// TaskState + enum TaskState: String, CaseIterable, Codable { + case cancelled = "CANCELLED" + + case failed = "FAILED" + + case pending = "PENDING" + + case running = "RUNNING" + + case succeeded = "SUCCEEDED" + } +} + extension Enums { /// TypeaheadSearchErrorCode enum TypeaheadSearchErrorCode: String, CaseIterable, Codable { @@ -35342,6 +38684,21 @@ extension Enums { // MARK: - Input Objects enum InputObjects {} +extension InputObjects { + struct AddDiscoverFeedInput: Encodable, Hashable { + var url: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url, forKey: .url) + } + + enum CodingKeys: String, CodingKey { + case url + } + } +} + extension InputObjects { struct ArchiveLinkInput: Encodable, Hashable { var archived: Bool @@ -35679,6 +39036,55 @@ extension InputObjects { } } +extension InputObjects { + struct DeleteDiscoverArticleInput: Encodable, Hashable { + var discoverArticleId: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(discoverArticleId, forKey: .discoverArticleId) + } + + enum CodingKeys: String, CodingKey { + case discoverArticleId + } + } +} + +extension InputObjects { + struct DeleteDiscoverFeedInput: Encodable, Hashable { + var feedId: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(feedId, forKey: .feedId) + } + + enum CodingKeys: String, CodingKey { + case feedId + } + } +} + +extension InputObjects { + struct EditDiscoverFeedInput: Encodable, Hashable { + var feedId: String + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(feedId, forKey: .feedId) + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case feedId + case name + } + } +} + extension InputObjects { struct FeedsInput: Encodable, Hashable { var after: OptionalArgument = .absent() @@ -36159,6 +39565,29 @@ extension InputObjects { } } +extension InputObjects { + struct SaveDiscoverArticleInput: Encodable, Hashable { + var discoverArticleId: String + + var locale: OptionalArgument = .absent() + + var timezone: OptionalArgument = .absent() + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(discoverArticleId, forKey: .discoverArticleId) + if locale.hasValue { try container.encode(locale, forKey: .locale) } + if timezone.hasValue { try container.encode(timezone, forKey: .timezone) } + } + + enum CodingKeys: String, CodingKey { + case discoverArticleId + case locale + case timezone + } + } +} + extension InputObjects { struct SaveFileInput: Encodable, Hashable { var clientRequestId: String diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift index 9dea39b9a..161df3925 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift @@ -15,6 +15,8 @@ public enum RuleActionType { case delete case markAsRead case sendNotification + case export + case webhook static func from(_ other: Enums.RuleActionType) -> RuleActionType { switch other { @@ -28,6 +30,10 @@ public enum RuleActionType { return .sendNotification case .delete: return .delete + case Enums.RuleActionType.export: + return .export + case Enums.RuleActionType.webhook: + return .webhook } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift index 78117f8a0..f1251fdc0 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift @@ -17,7 +17,9 @@ public extension DataService { profileImageURL: try $0.profile( selection: .init { try $0.pictureUrl() } ), - intercomHash: try $0.intercomHash() + intercomHash: try $0.intercomHash(), + digestEnabled: true // (try $0.featureList(selection: featureSelection.list.nullable)? + // .filter { $0.enabled && $0.name == "digest" } ?? []).count > 0 ) } @@ -65,6 +67,7 @@ public struct ViewerInternal { public let name: String public let profileImageURL: String? public let intercomHash: String? + public let digestEnabled: Bool? func persist(context: NSManagedObjectContext) throws { try context.performAndWait { @@ -73,6 +76,7 @@ public struct ViewerInternal { viewer.username = username viewer.name = name viewer.profileImageURL = profileImageURL + viewer.digestEnabled = digestEnabled ?? false do { try context.save() diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeatureSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeatureSelection.swift new file mode 100644 index 000000000..b72a783a1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeatureSelection.swift @@ -0,0 +1,6 @@ +import Models +import SwiftGraphQL + +let featureSelection = Selection.Feature { + FeatureInternal(name: try $0.name(), enabled: try $0.grantedAt() != nil) +} From d2ea93a7dd1b0b4a3e54af821ec1ae41a9a47cf0 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 16 Apr 2024 13:31:55 +0800 Subject: [PATCH 15/33] Stub out some changes to the digest response --- packages/api/src/services/digest.ts | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index de2ca2909..21b34a01c 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -1,4 +1,5 @@ import { redisDataSource } from '../redis_data_source' +import { SpeechFile } from '@omnivore/text-to-speech-handler' export const CREATE_DIGEST_JOB = 'create-digest' @@ -17,6 +18,8 @@ export interface Digest { chapters?: Chapter[] urlsToAudio?: string[] jobState: string + + speechFile: SpeechFile } interface Chapter { @@ -26,6 +29,40 @@ interface Chapter { const digestKey = (userId: string) => `digest:${userId}` export const getDigest = async (userId: string): Promise => { + await redisDataSource.redisClient?.set( + digestKey(userId), + JSON.stringify({ + id: 'BB3D5D89-70A2-4AE1-ADDC-713232B1281D', + title: + 'SOTU response collapses, Trump hits new low, Biden fundraising explodes 3/11/24 TDPS Podcast', + content: 'content', + urlsToAudio: [], + jobState: 'completed', + speechFile: { + pageId: '1234', + wordCount: 2124, + language: 'en-US', + defaultVoice: 'en-US-ChristopherNeural', + utterances: [ + { + idx: '', + text: 'TOP prospect JOINS Canucks - Team SPEAKS OUT on Demko Injury | Canucks News', + wordOffset: 0, + wordCount: 14, + voice: 'en-US-ChristopherNeural', + }, + { + idx: '4', + text: 'Intro', + wordOffset: 14, + wordCount: 1, + voice: 'en-US-ChristopherNeural', + }, + ], + }, + }) + ) + const digest = await redisDataSource.redisClient?.get(digestKey(userId)) return digest ? (JSON.parse(digest) as Digest) : null } From 47150c29913ecece27a755509815eecc8642bd17 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 16 Apr 2024 13:34:12 +0800 Subject: [PATCH 16/33] Refactoring the UX for digest view --- .../App/Views/AI/FullScreenDigestView.swift | 303 ++++++++++++++++++ .../App/Views/AI/LibraryDigestView.swift | 267 --------------- .../AudioPlayer/ExpandedAudioPlayer.swift | 22 +- .../App/Views/AudioPlayer/MiniPlayer.swift | 4 +- .../Views/AudioPlayer/MiniPlayerViewer.swift | 12 +- .../App/Views/Home/HomeFeedViewIOS.swift | 73 +++-- .../Sources/App/Views/LibraryTabView.swift | 32 +- .../App/Views/PrimaryContentView.swift | 2 +- .../Views/WebReader/WebReaderContainer.swift | 20 +- .../Sources/Models/DataModels/FeedItem.swift | 39 ++- .../AudioSession/AudioController.swift | 78 ++++- .../AudioSession/SpeechSynthesizer.swift | 4 +- .../Services/DataService/AI/AITasks.swift | 118 +++++++ 13 files changed, 608 insertions(+), 366 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift delete mode 100644 apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift new file mode 100644 index 000000000..d2472344f --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift @@ -0,0 +1,303 @@ +import SwiftUI +import Models +import Services + +public class FullScreenDigestViewModel: ObservableObject { + @Published var isLoading = false + @Published var digest: DigestResult? + + func load(dataService: DataService) async { + isLoading = true + if digest == nil { + do { + digest = try await dataService.getLatestDigest(timeoutInterval: 10) + } catch { + print("ERROR WITH DIGEST: ", error) + } + } + isLoading = false + } +} + +struct DigestAudioItem: AudioItemProperties { + let audioItemType = Models.AudioItemType.digest + + var itemID = "" + + var title = "TITLE" + + var byline: String? = "byline" + + var imageURL: URL? = nil + + var language: String? + + var startIndex: Int = 0 + var startOffset: Double = 0.0 +} + +@available(iOS 17.0, *) +@MainActor +struct FullScreenDigestView: View { + let viewModel: DigestViewModel = DigestViewModel() + let dataService: DataService + let audioController: AudioController + + @Environment(\.dismiss) private var dismiss + + let textBody = "In a significant political turn, the SOTU response faces unexpected collapse, " + + "marking a stark contrast to Trump's latest downturn, alongside an unprecedented " + + "surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + + "The analysis provides insights into the shifting dynamics of political support and " + + "the potential implications for future electoral strategies. Based on the information " + + "you provided, the video seems to discuss a recent event where former President " + + "Donald Trump made a controversial statement that shocked even his own audience. " + + "The video likely covers Trump's response to the State of the Union (SOTU) address " + + "and how it received negative feedback, possibly leading to a decline in his support " + + "or approval ratings. Additionally, it appears that the video touches upon a surge " + + "in fundraising for President Joe Biden's administration around March 11, 2024." + + public init(dataService: DataService, audioController: AudioController) { + self.dataService = dataService + self.audioController = audioController + } + + var body: some View { + // ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) { + Group { + if viewModel.isLoading { + ProgressView() + } else { + itemBody + .task { + await viewModel.load(dataService: dataService) + }.onAppear { + self.audioController.play(itemAudioProperties: DigestAudioItem()) + } + } + } .navigationTitle("Omnivore digest") + .navigationBarTitleDisplayMode(.inline) + +// HStack(alignment: .top) { +// Spacer() +// closeButton +// } +// .padding(20) + // } + } + + var closeButton: some View { + Button(action: { + dismiss() + }, label: { + ZStack { + Circle() + .foregroundColor(Color.appGrayText) + .frame(width: 36, height: 36) + .opacity(0.1) + + Image(systemName: "xmark") + .font(.appCallout) + .frame(width: 36, height: 36) + } + }) + .buttonStyle(.plain) + } + + @available(iOS 17.0, *) + var itemBody: some View { + VStack { + ScrollView(.vertical) { + VStack(spacing: 20) { + Text("SOTU response collapses, Trump hits new low, Biden fundraising explodes 3/11/24 TDPS Podcast") + .font(.title) + Text(textBody) + .font(.body) + } + } + // .scrollTargetBehavior(.paging) + // .ignoresSafeArea() + MiniPlayerViewer() + .padding(.top, 10) + .padding(.bottom, 40) + .background(Color.themeTabBarColor) + .onTapGesture { + // showExpandedAudioPlayer = true + } + } + } +} + +@MainActor +public class PreviewItemViewModel: ObservableObject { + let dataService: DataService + @Published var item: DigestItem + let showSwipeHint: Bool + + @Published var isLoading = false + @Published var resultText: String? + @Published var promptDisplayText: String? + + init(dataService: DataService, item: DigestItem, showSwipeHint: Bool) { + self.dataService = dataService + self.item = item + self.showSwipeHint = showSwipeHint + } + + func loadResult() async { +// isLoading = true +// let taskId = try? await dataService.createAITask( +// extraText: extraText, +// libraryItemId: item?.id ?? "", +// promptName: "summarize-001" +// ) +// +// if let taskId = taskId { +// do { +// let fetchedText = try await dataService.pollAITask(jobId: taskId, timeoutInterval: 30) +// resultText = fetchedText +// } catch { +// print("ERROR WITH RESULT TEXT: ", error) +// } +// } else { +// print("NO TASK ID: ", taskId) +// } +// isLoading = false + } +} + +@MainActor +struct PreviewItemView: View { + @StateObject var viewModel: PreviewItemViewModel + + var body: some View { + VStack(spacing: 10) { + HStack { + AsyncImage(url: viewModel.item.siteIcon) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 20, height: 20, alignment: .center) + } else { + Color.appButtonBackground + .frame(width: 20, height: 20, alignment: .center) + } + } + Text(viewModel.item.site) + .font(Font.system(size: 14)) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .padding(.top, 10) + Text(viewModel.item.title) + // .font(.body) + // .fontWeight(.semibold) + .font(Font.system(size: 18, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .topLeading) + + Text(viewModel.item.author) + .font(Font.system(size: 14)) + .foregroundColor(Color(hex: "898989")) + .frame(maxWidth: .infinity, alignment: .topLeading) + + Color(hex: "2A2A2A") + .frame(height: 1) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 20) + + if viewModel.isLoading { + ProgressView() + .task { + await viewModel.loadResult() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Text(viewModel.item.summaryText) + .font(Font.system(size: 16)) + // .font(.body) + .lineSpacing(12.0) + .frame(maxWidth: .infinity, alignment: .topLeading) + HStack { + Button(action: {}, label: { + HStack(alignment: .center) { + Text("Start listening") + .font(Font.system(size: 14)) + .frame(height: 42, alignment: .center) + Image(systemName: "play.fill") + .resizable() + .frame(width: 10, height: 10) + } + .padding(.horizontal, 15) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(18) + }) + Spacer() + } + .padding(.top, 20) + } + Spacer() + if viewModel.showSwipeHint { + VStack { + Image.doubleChevronUp + Text("Swipe up for next article") + .foregroundColor(Color(hex: "898989")) + } + .padding(.bottom, 50) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 100) + .padding(.horizontal, 15) + + } +} + +struct RatingView: View { + @State private var rating: Int = 0 + + var body: some View { + VStack(spacing: 30) { + Text("Rate today's digest") + .font(.title) + .padding(.vertical, 40) + Text("I liked the stories picked for today's digest") + RatingWidget() + + Text("The stories were interesting") + RatingWidget() + + Text("The voices sounded good") + RatingWidget() + + Text("I liked the music") + RatingWidget() + Spacer() + }.padding(.top, 60) + } +} + +struct StarView: View { + var isFilled: Bool + var body: some View { + Image(systemName: isFilled ? "star.fill" : "star") + .foregroundColor(isFilled ? Color.yellow : Color.gray) + } +} + +struct RatingWidget: View { + @State private var rating: Int = 0 + var body: some View { + HStack { + ForEach(1...5, id: \.self) { index in + StarView(isFilled: index <= rating) + .onTapGesture { + rating = index + } + } + } + .padding() + .background(Color(hex: "313131")) + .cornerRadius(8) + // .shadow(radius: 3) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift deleted file mode 100644 index e6f4f91d7..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift +++ /dev/null @@ -1,267 +0,0 @@ -import SwiftUI -import Models -import Services - - -struct DigestItem { - let id: String - let site: String - let siteIcon: URL? - let author: String - let title: String - let summaryText: String - let keyPointsText: String - let highlightsText: String -} - -@available(iOS 17.0, *) -@MainActor -struct LibraryDigestView: View { - let dataService: DataService - // @State private var currentIndex = 0 - @State private var items: [DigestItem] - // @State private var preloadedItems: [Int: String] = [:] - @Environment(\.dismiss) private var dismiss - - // let itemCount = 10 // Number of items to initially load - // let prefetchCount = 2 // Number of items to prefetch - - public init(dataService: DataService) { - self.dataService = dataService - self.items = [ - DigestItem( - id: "1468AFAA-88sdfsdfC-4546-BE02-EACF385288FC", - site: "CNBC.com", - siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), - author: "Kif Leswing", - title: "Apple shares just had their best day since last May", - summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + - " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + - "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + - "electoral strategies. ", - keyPointsText: "Key points from the article:", - highlightsText: "Highlights from the article:" - ), - DigestItem( - id: "1468AFAA-8sdfsdffsdf-4546-BE02-EACF385288FC", - site: "CNBC.com", - siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), - author: "Kif Leswing", - title: "Apple shares just had their best day since last May", - summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + - " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + - "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + - "electoral strategies. ", - keyPointsText: "Key points from the article:", - highlightsText: "Highlights from the article:" - ), - DigestItem( - id: "1468AFAA-882C-asdadfsa85288FC", - site: "CNBC.com", - siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), - author: "Kif Leswing", - title: "Apple shares just had their best day since last May", - summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + - " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + - "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + - "electoral strategies. ", - keyPointsText: "Key points from the article:", - highlightsText: "Highlights from the article:" - ) - ] - // currentIndex = 0 - // _preloadedItems = [Int:String] - } - - var body: some View { - itemBody - } - - @available(iOS 17.0, *) - var itemBody: some View { - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(Array(self.items.enumerated()), id: \.1.id) { idx, item in - PreviewItemView( - viewModel: PreviewItemViewModel(dataService: dataService, item: item, showSwipeHint: idx == 0) - ) - .containerRelativeFrame([.horizontal, .vertical]) - } - } - .scrollTargetLayout() - } - .scrollTargetBehavior(.paging) - .ignoresSafeArea() - - - // ScrollView(.horizontal, showsIndicators: false) { - // HStack(spacing: 0) { - // ForEach(items.indices, id: \.self) { index in - // if let item = preloadedItems[index] { - // ItemView(content: item) - // .onAppear { - // if index == items.count - prefetchCount { - // fetchItems() - // } - // } - // } - // } - // } - // } - // .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - // fetchItems() - } - .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in - // Pause any background tasks if necessary - } - - } - -// private func fetchItems() { -// // Simulate fetching items from an API -// for idx in currentIndex.. some View { - if let imageURL = itemAudioProperties.imageURL { + func artwork(_ itemAudioProperties: AudioItemProperties?, forDimensions dim: Double) -> some View { + if let imageURL = itemAudioProperties?.imageURL { return AnyView(AsyncImage(url: imageURL) { phase in if let image = phase.image { image @@ -125,9 +123,9 @@ Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote) Spacer(minLength: 0) } else { - artwork(itemAudioProperties, forDimensions: 50) + artwork(audioController.itemAudioProperties, forDimensions: 50) - Text(itemAudioProperties.title) + Text(audioController.itemAudioProperties?.title ?? "") .font(Font.system(size: 17, weight: .medium)) .fixedSize(horizontal: false, vertical: true) .lineLimit(2) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 52ae844be..3b22ac24e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -204,6 +204,9 @@ struct AnimatingCellHeight: AnimatableModifier { @ObservedObject var viewModel: HomeFeedViewModel @State private var selection = Set() + @AppStorage("LibraryList::digestEnabled") var digestEnabled = false + @AppStorage("LibraryList::hasCheckedForDigestFeature") var hasCheckedForDigestFeature = false + init(viewModel: HomeFeedViewModel, isEditMode: Binding) { _viewModel = ObservedObject(wrappedValue: viewModel) _isEditMode = isEditMode @@ -225,7 +228,7 @@ struct AnimatingCellHeight: AnimatableModifier { } var body: some View { - ZStack { + ZStack { HomeFeedView( listTitle: $listTitle, isListScrolled: $isListScrolled, @@ -265,8 +268,8 @@ struct AnimatingCellHeight: AnimatableModifier { VStack(spacing: 0) { Spacer() - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .padding(.top, 10) .padding(.bottom, 20) .background(Color.themeTabBarColor) @@ -317,6 +320,15 @@ struct AnimatingCellHeight: AnimatableModifier { } ) } + .fullScreenCover(isPresented: $showLibraryDigest) { + if #available(iOS 17.0, *) { + NavigationView { + FullScreenDigestView(dataService: dataService, audioController: audioController) + } + } else { + Text("Sorry digest is only available on iOS 17 and above") + } + } .toolbar { toolbarItems } @@ -339,6 +351,20 @@ struct AnimatingCellHeight: AnimatableModifier { viewModel.stopUsingFollowingPrimer = true } } + .task { + do { + if let viewer = try await dataService.fetchViewer() { + digestEnabled = viewer.digestEnabled ?? false + if !hasCheckedForDigestFeature { + hasCheckedForDigestFeature = true + // selectedTab = "digest" + } + } + } catch { + print("ERROR FETCHING VIEWER: ", error) + print("") + } + } .environment(\.editMode, self.$isEditMode) .navigationBarTitleDisplayMode(.inline) } @@ -383,15 +409,14 @@ struct AnimatingCellHeight: AnimatableModifier { if isEditMode == .active { Button(action: { isEditMode = .inactive }, label: { Text("Cancel") }) } else { - -// if #available(iOS 17.0, *) { -// Button( -// action: { showLibraryDigest = true }, -// label: { Image(systemName: "sparkles") } -// ) -// .buttonStyle(.plain) -// .padding(.trailing, 4) -// } + if #available(iOS 17.0, *) { + Button( + action: { showLibraryDigest = true }, + label: { Image.tabDigestSelected } + ) + .buttonStyle(.plain) + .padding(.trailing, 4) + } if prefersListLayout { Button( action: { isEditMode = isEditMode == .active ? .inactive : .active }, @@ -481,12 +506,12 @@ struct AnimatingCellHeight: AnimatableModifier { let showFeatureCards: Bool var slideTransition: PresentationLinkTransition { PresentationLinkTransition.slide( - options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, - options: - PresentationLinkTransition.Options( - modalPresentationCapturesStatusBarAppearance: true - ) - )) + options: PresentationLinkTransition.SlideTransitionOptions( + edge: .trailing, + options: PresentationLinkTransition.Options( + modalPresentationCapturesStatusBarAppearance: true + ) + )) } var body: some View { @@ -494,12 +519,12 @@ struct AnimatingCellHeight: AnimatableModifier { if let linkRequest = viewModel.linkRequest, viewModel.currentListConfig?.hasReadNowSection ?? false { PresentationLink( transition: PresentationLinkTransition.slide( - options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, - options: - PresentationLinkTransition.Options( - modalPresentationCapturesStatusBarAppearance: true, - preferredPresentationBackgroundColor: ThemeManager.currentBgColor - ))), + options: PresentationLinkTransition.SlideTransitionOptions( + edge: .trailing, + options: PresentationLinkTransition.Options( + modalPresentationCapturesStatusBarAppearance: true, + preferredPresentationBackgroundColor: ThemeManager.currentBgColor + ))), isPresented: $viewModel.presentWebContainer, destination: { WebReaderLoadingContainer(requestID: linkRequest.serverID) diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index 79f9137b7..e98786b5a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Jackson Harper on 6/29/23. -// - import Foundation import Models import Services @@ -21,9 +14,6 @@ struct LibraryTabView: View { @AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false @AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox" - @AppStorage("LibraryTabView::digestEnabled") var digestEnabled = false - @AppStorage("LibraryTabView::hasCheckedForDigestFeature") var hasCheckedForDigestFeature = false - @State var isEditMode: EditMode = .inactive @State var showExpandedAudioPlayer = false @State var presentPushContainer = true @@ -79,6 +69,8 @@ struct LibraryTabView: View { @State var operationStatus: OperationStatus = .none @State var operationMessage: String? + @State var digestEnabled = false + var showDigest: Bool { if digestEnabled, #available(iOS 17.0, *) { return true @@ -143,7 +135,7 @@ struct LibraryTabView: View { if showDigest, #available(iOS 17.0, *) { NavigationView { - LibraryDigestView(dataService: dataService) + DigestView(dataService: dataService) .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) }.tag("digest") @@ -165,8 +157,8 @@ struct LibraryTabView: View { } } - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .onTapGesture { showExpandedAudioPlayer = true } @@ -236,19 +228,5 @@ struct LibraryTabView: View { } selectedTab = "inbox" } - .task { - do { - if let viewer = try await dataService.fetchViewer() { - digestEnabled = viewer.digestEnabled ?? false - if !hasCheckedForDigestFeature { - hasCheckedForDigestFeature = true - selectedTab = "digest" - } - } - } catch { - print("ERROR FETCHING VIEWER: ", error) - print("") - } - } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index 634553453..34de3e874 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -52,7 +52,7 @@ import Transmission return AnyView(splitView) #endif } - + func startTimer(amount: Int) { self.snackbarTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(amount / 1000), repeats: false) { _ in DispatchQueue.main.async { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 5eebee5ee..93491b275 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -133,7 +133,7 @@ struct WebReaderContainerView: View { #if os(iOS) var audioNavbarItem: some View { - if !audioController.playbackError, audioController.isLoadingItem(itemID: item.unwrappedID) { + if audioController.isLoadingItem(audioController.itemAudioProperties) { return AnyView(ProgressView() .padding(.horizontal)) } else { @@ -500,20 +500,6 @@ struct WebReaderContainerView: View { .formSheet(isPresented: $showOpenArchiveSheet) { OpenArchiveTodayView(item: item) } - .formSheet(isPresented: $showExplainSheet) { - ExplainView( - viewModel: ExplainViewModel( - dataService: dataService, - item: self.item, - promptName: "explain-text-001", - extraText: viewModel.explainText - ), - dismissAction: { - viewModel.explainText = nil - showExplainSheet = false - } - ) - } #endif .sheet(isPresented: $showHighlightAnnotationModal) { NavigationView { @@ -629,8 +615,8 @@ struct WebReaderContainerView: View { .offset(y: navBarVisible ? 0 : -150) Spacer() - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .padding(.top, 10) .padding(.bottom, showBottomBar ? 10 : 40) .background(Color.themeTabBarColor) diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 53aa7a504..cbf918195 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -27,8 +27,7 @@ public struct LinkedItemSyncResult { hasMore: Bool, mostRecentUpdatedAt: Date?, oldestUpdatedAt: Date?, - isEmpty: Bool) - { + isEmpty: Bool) { self.updatedItemIDs = updatedItemIDs self.cursor = cursor self.hasMore = hasMore @@ -38,7 +37,41 @@ public struct LinkedItemSyncResult { } } -public struct LinkedItemAudioProperties { +public enum AudioItemType { + case digest + case libraryItem +} + +public protocol AudioItemProperties { + var audioItemType: AudioItemType { + get + } + var itemID: String { + get + } + var title: String { + get + } + var byline: String? { + get + } + var imageURL: URL? { + get + } + var language: String? { + get + } + var startIndex: Int { + get + } + var startOffset: Double { + get + } +} + +public struct LinkedItemAudioProperties: AudioItemProperties { + public let audioItemType = AudioItemType.libraryItem + public let itemID: String public let objectID: NSManagedObjectID public let title: String diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index 318c3388a..313265e66 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -33,7 +33,7 @@ @Published public var currentAudioIndex: Int = 0 @Published public var readText: String = "" @Published public var unreadText: String = "" - @Published public var itemAudioProperties: LinkedItemAudioProperties? + @Published public var itemAudioProperties: AudioItemProperties? @Published public var timeElapsed: TimeInterval = 0 @Published public var duration: TimeInterval = 0 @@ -72,7 +72,7 @@ observer = nil } - public func play(itemAudioProperties: LinkedItemAudioProperties) { + public func play(itemAudioProperties: AudioItemProperties) { stop() playbackError = false @@ -539,11 +539,14 @@ state == .playing } - public func isLoadingItem(itemID: String) -> Bool { + public func isLoadingItem(_ audioItem: AudioItemProperties?) -> Bool { if state == .reachedEnd { return false } - return itemAudioProperties?.itemID == itemID && isLoading + if audioItem?.itemID == nil { + return false + } + return itemAudioProperties?.itemID == audioItem?.itemID && isLoading } public func isPlayingItem(itemID: String) -> Bool { @@ -860,8 +863,19 @@ } return "" } - + func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { + switch(self.itemAudioProperties?.audioItemType) { + case .digest: + return try await downloadDigestItemSpeechFile(itemID: itemID, priority: priority) + case .libraryItem: + return try await downloadLibraryItemSpeechFile(itemID: itemID, priority: priority) + case .none: + return nil + } + } + + func downloadLibraryItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { let decoder = JSONDecoder() let speechFileUrl = pathForSpeechFile(itemID: itemID) @@ -910,6 +924,60 @@ return nil } + func downloadDigestItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { + let decoder = JSONDecoder() + let speechFileUrl = URL.om_documentsDirectory.appendingPathComponent("digest").appendingPathComponent("speech-\(currentVoice).json") + + if FileManager.default.fileExists(atPath: speechFileUrl.path) { + let data = try Data(contentsOf: speechFileUrl) + document = try decoder.decode(SpeechDocument.self, from: data) + // If we can't load it from disk we make the API call + if let document = document { + return document + } + } + + let path = "/api/digest/v1/" + guard let url = URL(string: path, relativeTo: dataService.appEnvironment.serverBaseURL) else { + throw BasicError.message(messageText: "Invalid audio URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + for (header, value) in dataService.networker.defaultHeaders { + request.setValue(value, forHTTPHeaderField: header) + } + + let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request) + guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else { + throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.") + } + + guard let data = result?.0 else { + throw BasicError.message(messageText: "audioFetch failed. no data received.") + } + + let str = String(decoding: data, as: UTF8.self) + print("result digest file: ", str) + + do { + let digest = try JSONDecoder().decode(DigestResult.self, from: data) + let directory = URL.om_documentsDirectory.appendingPathComponent("digest") + // do { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: speechFileUrl) + return digest.speechFile +// } catch { +// print("error writing file", error) +// } + // } + } catch { + print("error with digest file", error) + } + + return nil + } + func getSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { document = try await downloadSpeechFile(itemID: itemID, priority: priority) return document diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift index 2939c2cf4..2946c0fb0 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift @@ -20,7 +20,7 @@ struct UtteranceRequest: Codable { let isOpenAIVoice: Bool } -struct Utterance: Decodable { +public struct Utterance: Decodable { public let idx: String public let text: String public let voice: String? @@ -39,7 +39,7 @@ struct Utterance: Decodable { } } -struct SpeechDocument: Decodable { +public struct SpeechDocument: Decodable { static let averageWPM: Double = 195 public let pageId: String diff --git a/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift b/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift new file mode 100644 index 000000000..7ea58022c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift @@ -0,0 +1,118 @@ +import CoreData +import Foundation +import Models +import Utils + +struct AITaskRequest: Decodable { + public let requestId: String +} + +public struct DigestResult: Decodable { + public let id: String + public let title: String + public let content: String + public let urlsToAudio: [String] + public let speechFile: SpeechDocument + + public let jobState: String +} + +public struct DigestItem: Decodable { + public let id: String + public let site: String + public let siteIcon: URL? + public let author: String + public let title: String + public let summaryText: String + public let keyPointsText: String + public let highlightsText: String + public init(id: String, site: String, siteIcon: URL?, + author: String, title: String, summaryText: String, + keyPointsText: String, highlightsText: String) { + self.id = id + self.site = site + self.siteIcon = siteIcon + self.author = author + self.title = title + self.summaryText = summaryText + self.keyPointsText = keyPointsText + self.highlightsText = highlightsText + } +} + +extension DataService { +// public func createAITask(extraText: String?, libraryItemId: String, promptName: String) async throws -> String? { +// let jsonData = try JSONSerialization.data(withJSONObject: [ +// "libraryItemId": libraryItemId, +// "promptName": promptName, +// "extraText": extraText +// ]) +// +// let urlRequest = URLRequest.create( +// baseURL: appEnvironment.serverBaseURL, +// urlPath: "/api/ai-task", +// requestMethod: .post(params: jsonData), +// includeAuthToken: true +// ) +// let resource = ServerResource( +// urlRequest: urlRequest, +// decode: AITaskRequest.decode +// ) +// +// do { +// let taskRequest = try await networker.urlSession.performRequest(resource: resource) +// return taskRequest.requestId +// } catch { +// return nil +// } +// } + + // Function to poll the status of the AI task with timeout + public func getLatestDigest(timeoutInterval: TimeInterval) async throws -> DigestResult? { + var count = 0 + let startTime = Date() + while true { + count += 1 + if count > 3 { + return nil + } + do { + // Check if timeout has occurred + if -startTime.timeIntervalSinceNow >= timeoutInterval { + throw NSError(domain: "Timeout Error", code: -1, userInfo: nil) + } + + let urlRequest = URLRequest.create( + baseURL: appEnvironment.serverBaseURL, + urlPath: "/api/digest/v1/", + requestMethod: .get, + includeAuthToken: true + ) + + let resource = ServerResource( + urlRequest: urlRequest, + decode: DigestResult.decode + ) + + do { + let digest = try await networker.urlSession.performRequest(resource: resource) + print("GOT RESPONSE: ", digest) + return digest + } catch { + print("ERROR FETCHING TASK: ", error) +// if let response = error as? ServerError { +// if response != .stillProcessing { +// return nil +// } +// } + } + // Wait for some time before polling again + try? await Task.sleep(nanoseconds: 3_000_000_000) + } catch let error { + throw error + } + } + } +} + + From fbaa9ce66358017963cc2146e328d2ac84a8affc Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 16 Apr 2024 16:55:46 +0800 Subject: [PATCH 17/33] Digest job --- packages/api/src/jobs/ai/create_digest.ts | 167 ++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 packages/api/src/jobs/ai/create_digest.ts diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts new file mode 100644 index 000000000..acf1d827e --- /dev/null +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -0,0 +1,167 @@ +import { logger } from '../../utils/logger' +import { OpenAI } from '@langchain/openai' +import { PromptTemplate } from '@langchain/core/prompts' +import { LibraryItem } from '../../entity/library_item' +import { CreateDigestJobData } from '../../services/digest' + +const USER_PROFILE_PROMPT = + 'Create a user profile based on the supplied titles\n\ntitles:\n{titles}' + +const SUMMARIZE_PROMPT = + 'Summarize the supplied article.\n\ntitle: {title}\nauthor: {author}\ncontent: {content}' + +// TODO: Makes multiple DB queries and combines the results +const getPreferencesList = (userId: string): Promise => { + // use the queries from the digest definitions to lookup preferences + // There should be a list of multiple queries we use. For now we can + // hardcode these queries: + // - query: "in:all is:read OR has:highlights sort:updated-desc wordsCount:>=20" + // count: 21 + // reason: "recently read or highlighted items that are not part of the digest" + // - query: "in:all is:read OR has:highlights sort:saved-asc wordsCount:>=20" + // count: 4 + // reason: "some older items that were interacted with" + return Promise.resolve([]) +} + +// TODO: Makes multiple DB queries and combines the results +const getCandidatesList = (userId: string): Promise => { + // use the queries from the digest definitions to lookup preferences + // There should be a list of multiple queries we use. For now we can + // hardcode these queries: + // - query: "in:all is:unread saved:last24hrs sort:saved-desc wordsCount:>=500" + // count: 100 + // reason: "most recent 100 items saved over 500 words + return Promise.resolve([]) +} + +// TODO: Takes a list of library items, and uses a prompt to generate +// a text representation of a user profile +const createUserProfile = async ( + preferences: LibraryItem[] +): Promise => { + const llm = new OpenAI({ + modelName: 'gpt-4-0125-preview', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + + const contextualTemplate = PromptTemplate.fromTemplate(USER_PROFILE_PROMPT) + + const chain = contextualTemplate.pipe(llm) + const result = await chain.invoke({ + titles: preferences.map((item) => `* ${item}`).join('\n'), + }) + + return result +} + +// TODO: Checks redis for a user profile, if not found creates one and writes +// it to redis +const findOrCreateUserProfile = async (userId: string): Promise => { + // check redis for user profile, return if found + // if not found + const preferences = await getPreferencesList(userId) + const profile = await createUserProfile(preferences) + // TODO: write to redis here + return profile +} + +type RankedItem = { + topic: string + summary?: string + libraryItem: LibraryItem +} + +// TODO: Uses OpenAI to rank all the titles based on the user profiles +const rankCandidates = async ( + candidates: LibraryItem[], + userProfile: string +): Promise => { + return Promise.resolve([]) +} + +// Does some grouping by topic while trying to maintain ranking +// adds some basic topic diversity +const chooseRankedSelections = (rankedCandidates: RankedItem[]) => { + const selected = [] + const rankedTopics = [] + const topicCount = {} as Record + + for (const item of rankedCandidates) { + if (selected.length >= 5) { + break + } + + topicCount[item.topic] = (topicCount[item.topic] || 0) + 1 + + if (topicCount[item.topic] <= 2) { + selected.push(item) + if (rankedTopics.indexOf(item.topic) === -1) { + rankedTopics.push(item.topic) + } + } + } + + console.log('rankedTopics: ', rankedTopics) + console.log('finalSelections: ', selected) + + const finalSelections = [] + + for (const topic of rankedTopics) { + const matches = selected.filter((item) => item.topic == topic) + finalSelections.push(...matches) + } + + console.log('finalSelections: ', finalSelections) + + return finalSelections +} + +// TODO: we could paralleize this step sending all the ranked candidates to openAI at once +const summarizeItems = async ( + rankedCandidates: RankedItem[] +): Promise => { + const llm = new OpenAI({ + modelName: 'gpt-4-0125-preview', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + + for (const item of rankedCandidates) { + const contextualTemplate = PromptTemplate.fromTemplate(SUMMARIZE_PROMPT) + + const chain = contextualTemplate.pipe(llm) + const summary = await chain.invoke({ + title: item.libraryItem.title, + author: item.libraryItem.author ?? '', + content: item.libraryItem.readableContent, // markdown content + }) + item.summary = summary + } + + return rankedCandidates +} + +// TODO: write the digest to redis here +const writeDigest = (userId: string, selections: RankedItem[]) => {} + +export const CreateDigestJob = async (jobData: CreateDigestJobData) => { + try { + const candidates = await getCandidatesList(jobData.userId) + const userProfile = await findOrCreateUserProfile(jobData.userId) + const rankedCandidates = await rankCandidates(candidates, userProfile) + const selections = chooseRankedSelections(rankedCandidates) + + const summaries = await summarizeItems(selections) + + // TODO: we should have a QA step here that does some + // basic checks to make sure the summaries are good. + + writeDigest(jobData.userId, summaries) + } catch (err) { + console.log('error creating summary: ', err) + } +} From bcddf556d5f8a2687bcad31bafff129905ccf0fc Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 16 Apr 2024 17:15:22 +0800 Subject: [PATCH 18/33] Write speech files to redis --- packages/api/src/jobs/ai/create_digest.ts | 54 +++++++++++++++++++++-- packages/api/src/services/digest.ts | 7 +-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index acf1d827e..2026e73ee 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -1,8 +1,11 @@ import { logger } from '../../utils/logger' +import { v4 as uuid } from 'uuid' + import { OpenAI } from '@langchain/openai' import { PromptTemplate } from '@langchain/core/prompts' import { LibraryItem } from '../../entity/library_item' import { CreateDigestJobData } from '../../services/digest' +import { htmlToSsmlItems } from '@omnivore/text-to-speech-handler' const USER_PROFILE_PROMPT = 'Create a user profile based on the supplied titles\n\ntitles:\n{titles}' @@ -145,8 +148,53 @@ const summarizeItems = async ( return rankedCandidates } +// TODO: we can use something more sophisticated to generate titles +const generateTitle = (selections: RankedItem[]): Promise => { + return Promise.resolve( + 'Omnivore digest: ' + + selections.map((item) => item.libraryItem.title).join(',') + ) +} + // TODO: write the digest to redis here -const writeDigest = (userId: string, selections: RankedItem[]) => {} +// export interface Digest { +// jobState: string + +// url?: string +// title?: string +// content?: string +// chapters?: Chapter[] + +// urlsToAudio?: string[] +// speechFiles?: SpeechFile[] +// } +// export interface SpeechFile { +// wordCount: number; +// language: string; +// defaultVoice: string; +// utterances: Utterance[]; +//} +const writeDigest = async (userId: string, selections: RankedItem[]) => { + const title = await generateTitle(selections) + const speechFiles = selections.map((selection) => { + // convert the summary item to a SpeechFile here + return { + wordCount: 0, + language: 'en', + defaultVoice: 'Josh', + utterances: [], + } + }) + const digest = { + id: uuid(), + title: title, + content: 'content', + urlsToAudio: [], + jobState: 'completed', + speechFiles: speechFiles, + } + // write to redis +} export const CreateDigestJob = async (jobData: CreateDigestJobData) => { try { @@ -160,8 +208,8 @@ export const CreateDigestJob = async (jobData: CreateDigestJobData) => { // TODO: we should have a QA step here that does some // basic checks to make sure the summaries are good. - writeDigest(jobData.userId, summaries) + await writeDigest(jobData.userId, summaries) } catch (err) { - console.log('error creating summary: ', err) + console.log('error creating digest: ', err) } } diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 21b34a01c..cb0f88141 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -12,14 +12,15 @@ export interface CreateDigestJobResponse { } export interface Digest { + jobState: string + url?: string title?: string content?: string chapters?: Chapter[] - urlsToAudio?: string[] - jobState: string - speechFile: SpeechFile + urlsToAudio?: string[] + speechFiles?: SpeechFile[] } interface Chapter { From daaf493d4bc2e4cdbe51f82ce424abde4228b272 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 16 Apr 2024 17:27:38 +0800 Subject: [PATCH 19/33] Update stubbed data --- packages/api/src/services/digest.ts | 46 +++++++++++++++-------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index cb0f88141..a41bb23f8 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -39,28 +39,30 @@ export const getDigest = async (userId: string): Promise => { content: 'content', urlsToAudio: [], jobState: 'completed', - speechFile: { - pageId: '1234', - wordCount: 2124, - language: 'en-US', - defaultVoice: 'en-US-ChristopherNeural', - utterances: [ - { - idx: '', - text: 'TOP prospect JOINS Canucks - Team SPEAKS OUT on Demko Injury | Canucks News', - wordOffset: 0, - wordCount: 14, - voice: 'en-US-ChristopherNeural', - }, - { - idx: '4', - text: 'Intro', - wordOffset: 14, - wordCount: 1, - voice: 'en-US-ChristopherNeural', - }, - ], - }, + speechFiles: [ + { + pageId: '1234', + wordCount: 2124, + language: 'en-US', + defaultVoice: 'en-US-ChristopherNeural', + utterances: [ + { + idx: '', + text: 'TOP prospect JOINS Canucks - Team SPEAKS OUT on Demko Injury | Canucks News', + wordOffset: 0, + wordCount: 14, + voice: 'en-US-ChristopherNeural', + }, + { + idx: '4', + text: 'Intro', + wordOffset: 14, + wordCount: 1, + voice: 'en-US-ChristopherNeural', + }, + ], + }, + ], }) ) From 71477b1712529bc235ab7d7367534e6a42f5e781 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 16 Apr 2024 19:30:40 +0800 Subject: [PATCH 20/33] implement create digest job --- packages/api/package.json | 1 + packages/api/src/jobs/ai/create_digest.ts | 297 +++++++++++++++------- packages/api/src/queue-processor.ts | 4 +- packages/api/src/routers/digest_router.ts | 14 +- packages/api/src/services/digest.ts | 62 ++--- packages/api/src/utils/createTask.ts | 21 +- packages/api/src/utils/parser.ts | 4 + yarn.lock | 5 + 8 files changed, 254 insertions(+), 154 deletions(-) diff --git a/packages/api/package.json b/packages/api/package.json index 469586434..cea023808 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -113,6 +113,7 @@ "voca": "^1.4.0", "winston": "^3.3.3", "word-counting": "^1.1.4", + "yaml": "^2.4.1", "youtubei": "1.4.0" }, "devDependencies": { diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index 2026e73ee..feb12f78d 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -4,17 +4,68 @@ import { v4 as uuid } from 'uuid' import { OpenAI } from '@langchain/openai' import { PromptTemplate } from '@langchain/core/prompts' import { LibraryItem } from '../../entity/library_item' -import { CreateDigestJobData } from '../../services/digest' -import { htmlToSsmlItems } from '@omnivore/text-to-speech-handler' +import { htmlToSpeechFile, SpeechFile } from '@omnivore/text-to-speech-handler' +import axios from 'axios' +import { searchLibraryItems } from '../../services/library_item' +import { redisDataSource } from '../../redis_data_source' +import { htmlToMarkdown } from '../../utils/parser' +import yaml from 'yaml' +import { JsonOutputParser } from '@langchain/core/output_parsers' +import showdown from 'showdown' +import { Digest, writeDigest } from '../../services/digest' -const USER_PROFILE_PROMPT = - 'Create a user profile based on the supplied titles\n\ntitles:\n{titles}' +interface Selector { + query: string + count: number + reason: string +} -const SUMMARIZE_PROMPT = - 'Summarize the supplied article.\n\ntitle: {title}\nauthor: {author}\ncontent: {content}' +interface ZeroShotDefinition { + userPreferencesProfilePrompt: string + rankPrompt: string +} -// TODO: Makes multiple DB queries and combines the results -const getPreferencesList = (userId: string): Promise => { +interface DigestDefinition { + name: string + preferenceSelectors: Selector[] + candidateSelectors: Selector[] + contentFeaturesPrompt: string + contentRatingPrompt: string + summaryPrompt: string + assemblePrompt: string + + zeroShot: ZeroShotDefinition +} + +export interface CreateDigestJobData { + userId: string +} + +export interface CreateDigestJobResponse { + jobId: string +} + +export const CREATE_DIGEST_JOB = 'create-digest' + +let digestDefinition: DigestDefinition + +const fetchDigestDefinition = async (): Promise => { + const promptFileUrl = process.env.PROMPT_FILE_URL + if (!promptFileUrl) { + const msg = 'PROMPT_FILE_URL not set' + logger.error(msg) + throw new Error(msg) + } + + // fetch the yaml file + const response = await axios.get(promptFileUrl) + + // parse the yaml file + return yaml.parse(response.data) as DigestDefinition +} + +// Makes multiple DB queries and combines the results +const getPreferencesList = async (userId: string): Promise => { // use the queries from the digest definitions to lookup preferences // There should be a list of multiple queries we use. For now we can // hardcode these queries: @@ -24,21 +75,72 @@ const getPreferencesList = (userId: string): Promise => { // - query: "in:all is:read OR has:highlights sort:saved-asc wordsCount:>=20" // count: 4 // reason: "some older items that were interacted with" - return Promise.resolve([]) + + const preferences = await Promise.all( + digestDefinition.preferenceSelectors.map(async (selector) => { + // use the selector to fetch items + const results = await searchLibraryItems( + { + query: selector.query, + size: selector.count, + }, + userId + ) + + return results.libraryItems + }) + ) + + // deduplicate and flatten the items + const dedupedPreferences = preferences + .flat() + .filter( + (item, index, self) => index === self.findIndex((t) => t.id === item.id) + ) + + return dedupedPreferences } -// TODO: Makes multiple DB queries and combines the results -const getCandidatesList = (userId: string): Promise => { +// Makes multiple DB queries and combines the results +const getCandidatesList = async (userId: string): Promise => { // use the queries from the digest definitions to lookup preferences // There should be a list of multiple queries we use. For now we can // hardcode these queries: // - query: "in:all is:unread saved:last24hrs sort:saved-desc wordsCount:>=500" // count: 100 // reason: "most recent 100 items saved over 500 words - return Promise.resolve([]) + + const candidates = await Promise.all( + digestDefinition.candidateSelectors.map(async (selector) => { + // use the selector to fetch items + const results = await searchLibraryItems( + { + includeContent: true, + query: selector.query, + size: selector.count, + }, + userId + ) + + return results.libraryItems + }) + ) + + // deduplicate and flatten the items + const dedupedCandidates = candidates + .flat() + .filter( + (item, index, self) => index === self.findIndex((t) => t.id === item.id) + ) + .map((item) => ({ + ...item, + readableContent: htmlToMarkdown(item.readableContent), + })) // convert the html content to markdown + + return dedupedCandidates } -// TODO: Takes a list of library items, and uses a prompt to generate +// Takes a list of library items, and uses a prompt to generate // a text representation of a user profile const createUserProfile = async ( preferences: LibraryItem[] @@ -50,39 +152,68 @@ const createUserProfile = async ( }, }) - const contextualTemplate = PromptTemplate.fromTemplate(USER_PROFILE_PROMPT) + const contextualTemplate = PromptTemplate.fromTemplate( + digestDefinition.zeroShot.userPreferencesProfilePrompt + ) const chain = contextualTemplate.pipe(llm) const result = await chain.invoke({ - titles: preferences.map((item) => `* ${item}`).join('\n'), + titles: preferences.map((item) => `* ${item.title}`).join('\n'), }) return result } -// TODO: Checks redis for a user profile, if not found creates one and writes +// Checks redis for a user profile, if not found creates one and writes // it to redis const findOrCreateUserProfile = async (userId: string): Promise => { // check redis for user profile, return if found + const key = `userProfile:${userId}` + const existingProfile = await redisDataSource.redisClient?.get(key) + if (existingProfile) { + return existingProfile + } + // if not found const preferences = await getPreferencesList(userId) const profile = await createUserProfile(preferences) - // TODO: write to redis here + + // write to redis here + await redisDataSource.redisClient?.set(key, profile) + return profile } type RankedItem = { topic: string - summary?: string + summary: string libraryItem: LibraryItem } -// TODO: Uses OpenAI to rank all the titles based on the user profiles +// Uses OpenAI to rank all the titles based on the user profiles const rankCandidates = async ( candidates: LibraryItem[], userProfile: string ): Promise => { - return Promise.resolve([]) + const llm = new OpenAI({ + modelName: 'gpt-4-0125-preview', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + + const contextualTemplate = PromptTemplate.fromTemplate( + digestDefinition.zeroShot.rankPrompt + ) + + const outputParser = new JsonOutputParser() + const chain = contextualTemplate.pipe(llm).pipe(outputParser) + const contextStr = await chain.invoke({ + userProfile, + titles: JSON.stringify(candidates.map((item) => item.title)), + }) + + return contextStr as RankedItem[] } // Does some grouping by topic while trying to maintain ranking @@ -107,8 +238,8 @@ const chooseRankedSelections = (rankedCandidates: RankedItem[]) => { } } - console.log('rankedTopics: ', rankedTopics) - console.log('finalSelections: ', selected) + logger.info('rankedTopics: ', rankedTopics) + logger.info('finalSelections: ', selected) const finalSelections = [] @@ -117,12 +248,11 @@ const chooseRankedSelections = (rankedCandidates: RankedItem[]) => { finalSelections.push(...matches) } - console.log('finalSelections: ', finalSelections) + logger.info('finalSelections: ', finalSelections) return finalSelections } -// TODO: we could paralleize this step sending all the ranked candidates to openAI at once const summarizeItems = async ( rankedCandidates: RankedItem[] ): Promise => { @@ -133,83 +263,78 @@ const summarizeItems = async ( }, }) - for (const item of rankedCandidates) { - const contextualTemplate = PromptTemplate.fromTemplate(SUMMARIZE_PROMPT) + const contextualTemplate = PromptTemplate.fromTemplate( + digestDefinition.summaryPrompt + ) + const chain = contextualTemplate.pipe(llm) - const chain = contextualTemplate.pipe(llm) - const summary = await chain.invoke({ + // send all the ranked candidates to openAI at once in a batch + const summaries = await chain.batch( + rankedCandidates.map((item) => ({ title: item.libraryItem.title, author: item.libraryItem.author ?? '', content: item.libraryItem.readableContent, // markdown content - }) - item.summary = summary - } + })) + ) + + summaries.forEach( + (summary, index) => (rankedCandidates[index].summary = summary) + ) return rankedCandidates } -// TODO: we can use something more sophisticated to generate titles -const generateTitle = (selections: RankedItem[]): Promise => { - return Promise.resolve( - 'Omnivore digest: ' + - selections.map((item) => item.libraryItem.title).join(',') - ) +// generate speech files from the summaries +const generateSpeechFiles = (rankedItems: RankedItem[]): SpeechFile[] => { + // convert the summaries from markdown to HTML + const converter = new showdown.Converter({ + backslashEscapesHTMLTags: true, + }) + + const speechFiles = rankedItems.map((item) => { + const html = converter.makeHtml(item.summary) + return htmlToSpeechFile({ + content: html, + options: {}, + }) + }) + + return speechFiles } -// TODO: write the digest to redis here -// export interface Digest { -// jobState: string +// TODO: we should have a QA step here that does some +// basic checks to make sure the summaries are good. +const filterSummaries = (summaries: RankedItem[]): RankedItem[] => { + return summaries.filter((item) => item.summary.length > 100) +} -// url?: string -// title?: string -// content?: string -// chapters?: Chapter[] +// TODO: we can use something more sophisticated to generate titles +const generateTitle = (selections: RankedItem[]): string => + 'Omnivore digest: ' + + selections.map((item) => item.libraryItem.title).join(',') -// urlsToAudio?: string[] -// speechFiles?: SpeechFile[] -// } -// export interface SpeechFile { -// wordCount: number; -// language: string; -// defaultVoice: string; -// utterances: Utterance[]; -//} -const writeDigest = async (userId: string, selections: RankedItem[]) => { - const title = await generateTitle(selections) - const speechFiles = selections.map((selection) => { - // convert the summary item to a SpeechFile here - return { - wordCount: 0, - language: 'en', - defaultVoice: 'Josh', - utterances: [], - } - }) - const digest = { +export const createDigestJob = async (jobData: CreateDigestJobData) => { + digestDefinition = await fetchDigestDefinition() + + const candidates = await getCandidatesList(jobData.userId) + const userProfile = await findOrCreateUserProfile(jobData.userId) + const rankedCandidates = await rankCandidates(candidates, userProfile) + const selections = chooseRankedSelections(rankedCandidates) + + const summaries = await summarizeItems(selections) + + const filteredSummaries = filterSummaries(summaries) + + const speechFiles = generateSpeechFiles(filteredSummaries) + const title = generateTitle(summaries) + const digest: Digest = { id: uuid(), - title: title, + title, content: 'content', urlsToAudio: [], jobState: 'completed', - speechFiles: speechFiles, - } - // write to redis -} - -export const CreateDigestJob = async (jobData: CreateDigestJobData) => { - try { - const candidates = await getCandidatesList(jobData.userId) - const userProfile = await findOrCreateUserProfile(jobData.userId) - const rankedCandidates = await rankCandidates(candidates, userProfile) - const selections = chooseRankedSelections(rankedCandidates) - - const summaries = await summarizeItems(selections) - - // TODO: we should have a QA step here that does some - // basic checks to make sure the summaries are good. - - await writeDigest(jobData.userId, summaries) - } catch (err) { - console.log('error creating digest: ', err) + speechFiles, } + + await writeDigest(jobData.userId, digest) } diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index dac1f74d9..5f0fc6a21 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -16,6 +16,7 @@ import { appDataSource } from './data_source' import { env } from './env' import { TaskState } from './generated/graphql' import { aiSummarize, AI_SUMMARIZE_JOB_NAME } from './jobs/ai-summarize' +import { createDigestJob, CREATE_DIGEST_JOB } from './jobs/ai/create_digest' import { bulkAction, BULK_ACTION_JOB_NAME } from './jobs/bulk_action' import { callWebhook, CALL_WEBHOOK_JOB_NAME } from './jobs/call_webhook' import { findThumbnail, THUMBNAIL_JOB } from './jobs/find_thumbnail' @@ -65,7 +66,6 @@ import { } from './jobs/email/inbound_emails' export const QUEUE_NAME = 'omnivore-backend-queue' -export const LONG_RUNNING_QUEUE_NAME = 'omnivore-backend-long-running-queue' export const JOB_VERSION = 'v001' export const getBackendQueue = async ( @@ -180,6 +180,8 @@ export const createWorker = (connection: ConnectionOptions) => return saveNewsletterJob(job.data) case FORWARD_EMAIL_JOB: return forwardEmailJob(job.data) + case CREATE_DIGEST_JOB: + return createDigestJob(job.data) default: logger.warning(`[queue-processor] unhandled job: ${job.name}`) } diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index f2c5ec65d..0a7de6cf9 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -1,13 +1,9 @@ import cors from 'cors' import express from 'express' import { env } from '../env' -import { - createJobId, - getJob, - jobStateToTaskState, - LONG_RUNNING_QUEUE_NAME, -} from '../queue-processor' -import { CREATE_DIGEST_JOB, getDigest } from '../services/digest' +import { CREATE_DIGEST_JOB } from '../jobs/ai/create_digest' +import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' +import { getDigest } from '../services/digest' import { findActiveUser } from '../services/user' import { analytics } from '../utils/analytics' import { getClaimsByToken, getTokenByRequest } from '../utils/auth' @@ -70,7 +66,7 @@ export function digestRouter() { // if yes then return 202 accepted // else enqueue job const jobId = createJobId(CREATE_DIGEST_JOB, userId) - const existingJob = await getJob(jobId, LONG_RUNNING_QUEUE_NAME) + const existingJob = await getJob(jobId) if (existingJob) { logger.info(`Job already in queue: ${jobId}`) return res.sendStatus(202) @@ -118,7 +114,7 @@ export function digestRouter() { // get job by user id const jobId = createJobId(CREATE_DIGEST_JOB, userId) - const job = await getJob(jobId, LONG_RUNNING_QUEUE_NAME) + const job = await getJob(jobId) if (job) { // if job is in queue then return job state const jobState = await job.getState() diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index a41bb23f8..271e79765 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -1,17 +1,9 @@ import { redisDataSource } from '../redis_data_source' import { SpeechFile } from '@omnivore/text-to-speech-handler' - -export const CREATE_DIGEST_JOB = 'create-digest' - -export interface CreateDigestJobData { - userId: string -} - -export interface CreateDigestJobResponse { - jobId: string -} +import { logger } from '../utils/logger' export interface Digest { + id: string jobState: string url?: string @@ -30,42 +22,20 @@ interface Chapter { const digestKey = (userId: string) => `digest:${userId}` export const getDigest = async (userId: string): Promise => { - await redisDataSource.redisClient?.set( - digestKey(userId), - JSON.stringify({ - id: 'BB3D5D89-70A2-4AE1-ADDC-713232B1281D', - title: - 'SOTU response collapses, Trump hits new low, Biden fundraising explodes 3/11/24 TDPS Podcast', - content: 'content', - urlsToAudio: [], - jobState: 'completed', - speechFiles: [ - { - pageId: '1234', - wordCount: 2124, - language: 'en-US', - defaultVoice: 'en-US-ChristopherNeural', - utterances: [ - { - idx: '', - text: 'TOP prospect JOINS Canucks - Team SPEAKS OUT on Demko Injury | Canucks News', - wordOffset: 0, - wordCount: 14, - voice: 'en-US-ChristopherNeural', - }, - { - idx: '4', - text: 'Intro', - wordOffset: 14, - wordCount: 1, - voice: 'en-US-ChristopherNeural', - }, - ], - }, - ], - }) - ) - const digest = await redisDataSource.redisClient?.get(digestKey(userId)) return digest ? (JSON.parse(digest) as Digest) : null } + +export const writeDigest = async (userId: string, digest: Digest) => { + // write to redis + const result = await redisDataSource.redisClient?.set( + digestKey(userId), + JSON.stringify(digest) + ) + + if (!result) { + const msg = `Error while writing digest to redis: ${userId}` + logger.error(msg) + throw new Error(msg) + } +} diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index d64471252..cb835da13 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -16,6 +16,11 @@ import { CreateLabelInput, } from '../generated/graphql' import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize' +import { + CreateDigestJobData, + CreateDigestJobResponse, + CREATE_DIGEST_JOB, +} from '../jobs/ai/create_digest' import { BulkActionData, BULK_ACTION_JOB_NAME } from '../jobs/bulk_action' import { CallWebhookJobData, CALL_WEBHOOK_JOB_NAME } from '../jobs/call_webhook' import { THUMBNAIL_JOB } from '../jobs/find_thumbnail' @@ -44,18 +49,8 @@ import { UPDATE_HIGHLIGHT_JOB, UPDATE_LABELS_JOB, } from '../jobs/update_db' -import { - createJobId, - getBackendQueue, - JOB_VERSION, - LONG_RUNNING_QUEUE_NAME, -} from '../queue-processor' +import { createJobId, getBackendQueue, JOB_VERSION } from '../queue-processor' import { redisDataSource } from '../redis_data_source' -import { - CreateDigestJobData, - CreateDigestJobResponse, - CREATE_DIGEST_JOB, -} from '../services/digest' import { signFeatureToken } from '../services/features' import { OmnivoreAuthorizationHeader } from './auth' import { CreateTaskError } from './errors' @@ -93,6 +88,7 @@ export const getJobPriority = (jobName: string): number => { case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME: case `${REFRESH_FEED_JOB_NAME}_low`: case EXPORT_ITEM_JOB_NAME: + case CREATE_DIGEST_JOB: return 50 case EXPORT_ALL_ITEMS_JOB_NAME: case REFRESH_ALL_FEEDS_JOB_NAME: @@ -865,7 +861,7 @@ export const enqueueSendEmail = async (jobData: SendEmailJobData) => { export const enqueueCreateDigest = async ( data: CreateDigestJobData ): Promise => { - const queue = await getBackendQueue(LONG_RUNNING_QUEUE_NAME) + const queue = await getBackendQueue() if (!queue) { throw new Error('No queue found') } @@ -876,6 +872,7 @@ export const enqueueCreateDigest = async ( removeOnComplete: true, removeOnFail: true, attempts: 3, + priority: getJobPriority(CREATE_DIGEST_JOB), }) logger.info('create digest job enqueued', { jobId: job.id }) diff --git a/packages/api/src/utils/parser.ts b/packages/api/src/utils/parser.ts index 395363634..3122a72bd 100644 --- a/packages/api/src/utils/parser.ts +++ b/packages/api/src/utils/parser.ts @@ -703,6 +703,10 @@ export const htmlToMarkdown = (html: string) => { return nhm.translate(/* html */ html) } +export const markdownToHtml = (markdown: string) => { + return nhm.translate(/* markdown */ markdown) +} + export const getDistillerResult = async ( uid: string, html: string diff --git a/yarn.lock b/yarn.lock index 034c6ee5c..9c651f55f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32048,6 +32048,11 @@ yaml@^2.2.1: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.0.tgz#2376db1083d157f4b3a452995803dbcf43b08140" integrity sha512-j9iR8g+/t0lArF4V6NE/QCfT+CO7iLqrXAHZbJdo+LfjqP1vR8Fg5bSiaq6Q2lOD1AUEVrEVIgABvBFYojJVYQ== +yaml@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed" + integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg== + yargs-parser@20.2.4: version "20.2.4" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" From 4bc1246a474bac66e3e5945a72b262e0020a0495 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 16 Apr 2024 20:23:29 +0800 Subject: [PATCH 21/33] fixing html --- packages/api/src/jobs/ai/create_digest.ts | 58 +++++++++++++++++------ packages/api/src/services/digest.ts | 4 +- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index feb12f78d..85c6423fb 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -14,6 +14,13 @@ import { JsonOutputParser } from '@langchain/core/output_parsers' import showdown from 'showdown' import { Digest, writeDigest } from '../../services/digest' +export interface CreateDigestJobData { + userId: string +} + +export interface CreateDigestJobResponse { + jobId: string +} interface Selector { query: string count: number @@ -37,12 +44,16 @@ interface DigestDefinition { zeroShot: ZeroShotDefinition } -export interface CreateDigestJobData { - userId: string +interface RankedItem { + topic: string + summary: string + libraryItem: LibraryItem } -export interface CreateDigestJobResponse { - jobId: string +interface RankedTitle { + topic: string + id: string + title: string } export const CREATE_DIGEST_JOB = 'create-digest' @@ -178,18 +189,12 @@ const findOrCreateUserProfile = async (userId: string): Promise => { const preferences = await getPreferencesList(userId) const profile = await createUserProfile(preferences) - // write to redis here - await redisDataSource.redisClient?.set(key, profile) + // write to redis here and ttl is 1 week + await redisDataSource.redisClient?.set(key, profile, 'EX', 60 * 60 * 24 * 7) return profile } -type RankedItem = { - topic: string - summary: string - libraryItem: LibraryItem -} - // Uses OpenAI to rank all the titles based on the user profiles const rankCandidates = async ( candidates: LibraryItem[], @@ -210,10 +215,31 @@ const rankCandidates = async ( const chain = contextualTemplate.pipe(llm).pipe(outputParser) const contextStr = await chain.invoke({ userProfile, - titles: JSON.stringify(candidates.map((item) => item.title)), + titles: JSON.stringify( + candidates.map((item) => ({ + id: item.id, + title: item.title, + })) + ), }) - return contextStr as RankedItem[] + logger.info('contextStr: ', contextStr) + // convert the json output to an array of ranked candidates + const rankedCandidate = contextStr as RankedTitle[] + + // map the ranked titles to the library items based on id + const rankedItems = rankedCandidate + .map((item) => { + const libraryItem = candidates.find((t) => t.id === item.id) + return { + topic: item.topic, + libraryItem, + summary: '', + } + }) + .filter((item) => item.libraryItem !== undefined) as RankedItem[] + + return rankedItems } // Does some grouping by topic while trying to maintain ranking @@ -292,7 +318,9 @@ const generateSpeechFiles = (rankedItems: RankedItem[]): SpeechFile[] => { }) const speechFiles = rankedItems.map((item) => { - const html = converter.makeHtml(item.summary) + const html = `
${converter.makeHtml( + item.summary + )}
` return htmlToSpeechFile({ content: html, options: {}, diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 271e79765..e3b38d8b7 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -30,7 +30,9 @@ export const writeDigest = async (userId: string, digest: Digest) => { // write to redis const result = await redisDataSource.redisClient?.set( digestKey(userId), - JSON.stringify(digest) + JSON.stringify(digest), + 'EX', + 60 * 60 * 24 * 7 // 1 week ) if (!result) { From 6bfb4842e2a5f26ec20a24c84f19d642170a172a Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 16 Apr 2024 21:27:20 +0800 Subject: [PATCH 22/33] fix idx --- packages/api/src/jobs/ai/create_digest.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index 85c6423fb..1c8bf442e 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -318,9 +318,12 @@ const generateSpeechFiles = (rankedItems: RankedItem[]): SpeechFile[] => { }) const speechFiles = rankedItems.map((item) => { - const html = `
${converter.makeHtml( - item.summary - )}
` + const html = ` +
+
+ ${converter.makeHtml(item.summary)} +
+
` return htmlToSpeechFile({ content: html, options: {}, From 47fd4d9663473f993e4d5df7c1f97ad7e31dcbcb Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 09:58:11 +0800 Subject: [PATCH 23/33] save id, url, thumbnail and createdAt in the digest --- packages/api/src/jobs/ai/create_digest.ts | 6 ++++++ packages/api/src/services/digest.ts | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index 1c8bf442e..d264f08f1 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -365,6 +365,12 @@ export const createDigestJob = async (jobData: CreateDigestJobData) => { urlsToAudio: [], jobState: 'completed', speechFiles, + libraryItems: filteredSummaries.map((item) => ({ + id: item.libraryItem.id, + url: item.libraryItem.originalUrl, + thumbnail: item.libraryItem.thumbnail ?? undefined, + })), + createdAt: new Date(), } await writeDigest(jobData.userId, digest) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index e3b38d8b7..1c9d20a05 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -2,9 +2,20 @@ import { redisDataSource } from '../redis_data_source' import { SpeechFile } from '@omnivore/text-to-speech-handler' import { logger } from '../utils/logger' +interface Chapter { + title: string +} + +interface LibraryItem { + id: string + url: string + thumbnail?: string +} + export interface Digest { id: string jobState: string + createdAt: Date url?: string title?: string @@ -13,10 +24,7 @@ export interface Digest { urlsToAudio?: string[] speechFiles?: SpeechFile[] -} - -interface Chapter { - title: string + libraryItems?: LibraryItem[] } const digestKey = (userId: string) => `digest:${userId}` From 6a4faf6f3c43d6e83a41e61846fd966e3d2d2f61 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 10:04:52 +0800 Subject: [PATCH 24/33] add description to the digest --- packages/api/src/jobs/ai/create_digest.ts | 12 ++++++++++-- packages/api/src/services/digest.ts | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index d264f08f1..a7c0e02c4 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -13,6 +13,7 @@ import yaml from 'yaml' import { JsonOutputParser } from '@langchain/core/output_parsers' import showdown from 'showdown' import { Digest, writeDigest } from '../../services/digest' +import { TaskState } from '../../generated/graphql' export interface CreateDigestJobData { userId: string @@ -344,6 +345,12 @@ const generateTitle = (selections: RankedItem[]): string => 'Omnivore digest: ' + selections.map((item) => item.libraryItem.title).join(',') +// TODO: generate description based on the summaries +const generateDescription = (selections: RankedItem[]): string => 'description' + +// TODO: generate content based on the summaries +const generateContent = (selections: RankedItem[]): string => 'content' + export const createDigestJob = async (jobData: CreateDigestJobData) => { digestDefinition = await fetchDigestDefinition() @@ -361,9 +368,9 @@ export const createDigestJob = async (jobData: CreateDigestJobData) => { const digest: Digest = { id: uuid(), title, - content: 'content', + content: generateContent(summaries), urlsToAudio: [], - jobState: 'completed', + jobState: TaskState.Succeeded, speechFiles, libraryItems: filteredSummaries.map((item) => ({ id: item.libraryItem.id, @@ -371,6 +378,7 @@ export const createDigestJob = async (jobData: CreateDigestJobData) => { thumbnail: item.libraryItem.thumbnail ?? undefined, })), createdAt: new Date(), + description: generateDescription(summaries), } await writeDigest(jobData.userId, digest) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 1c9d20a05..2149fade9 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -1,6 +1,7 @@ import { redisDataSource } from '../redis_data_source' import { SpeechFile } from '@omnivore/text-to-speech-handler' import { logger } from '../utils/logger' +import { TaskState } from '../generated/graphql' interface Chapter { title: string @@ -14,8 +15,9 @@ interface LibraryItem { export interface Digest { id: string - jobState: string + jobState: TaskState createdAt: Date + description: string url?: string title?: string From 259b778aba181abdde06a2d92dc331b75017e54d Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 12:16:15 +0800 Subject: [PATCH 25/33] add schedule and other parameters --- packages/api/src/jobs/ai/create_digest.ts | 28 +++++++++++++++++++---- packages/api/src/routers/digest_router.ts | 24 +++++++++++++++---- packages/api/src/services/digest.ts | 5 +--- packages/api/src/utils/createTask.ts | 10 +++++++- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index a7c0e02c4..fa7a975ff 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -4,7 +4,11 @@ import { v4 as uuid } from 'uuid' import { OpenAI } from '@langchain/openai' import { PromptTemplate } from '@langchain/core/prompts' import { LibraryItem } from '../../entity/library_item' -import { htmlToSpeechFile, SpeechFile } from '@omnivore/text-to-speech-handler' +import { + htmlToSpeechFile, + SpeechFile, + SSMLOptions, +} from '@omnivore/text-to-speech-handler' import axios from 'axios' import { searchLibraryItems } from '../../services/library_item' import { redisDataSource } from '../../redis_data_source' @@ -15,8 +19,13 @@ import showdown from 'showdown' import { Digest, writeDigest } from '../../services/digest' import { TaskState } from '../../generated/graphql' +export type CreateDigestJobSchedule = 'daily' | 'weekly' + export interface CreateDigestJobData { userId: string + voices?: string[] + language?: string + rate?: string } export interface CreateDigestJobResponse { @@ -312,7 +321,10 @@ const summarizeItems = async ( } // generate speech files from the summaries -const generateSpeechFiles = (rankedItems: RankedItem[]): SpeechFile[] => { +const generateSpeechFiles = ( + rankedItems: RankedItem[], + options: SSMLOptions +): SpeechFile[] => { // convert the summaries from markdown to HTML const converter = new showdown.Converter({ backslashEscapesHTMLTags: true, @@ -327,7 +339,7 @@ const generateSpeechFiles = (rankedItems: RankedItem[]): SpeechFile[] => { ` return htmlToSpeechFile({ content: html, - options: {}, + options, }) }) @@ -363,7 +375,11 @@ export const createDigestJob = async (jobData: CreateDigestJobData) => { const filteredSummaries = filterSummaries(summaries) - const speechFiles = generateSpeechFiles(filteredSummaries) + const speechFiles = generateSpeechFiles(filteredSummaries, { + ...jobData, + primaryVoice: jobData.voices?.[0], + secondaryVoice: jobData.voices?.[1], + }) const title = generateTitle(summaries) const digest: Digest = { id: uuid(), @@ -372,10 +388,12 @@ export const createDigestJob = async (jobData: CreateDigestJobData) => { urlsToAudio: [], jobState: TaskState.Succeeded, speechFiles, - libraryItems: filteredSummaries.map((item) => ({ + chapters: filteredSummaries.map((item, index) => ({ + title: item.libraryItem.title, id: item.libraryItem.id, url: item.libraryItem.originalUrl, thumbnail: item.libraryItem.thumbnail ?? undefined, + wordCount: speechFiles[index].wordCount, })), createdAt: new Date(), description: generateDescription(summaries), diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 0a7de6cf9..c415ea6c8 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -1,7 +1,10 @@ import cors from 'cors' import express from 'express' import { env } from '../env' -import { CREATE_DIGEST_JOB } from '../jobs/ai/create_digest' +import { + CreateDigestJobSchedule, + CREATE_DIGEST_JOB, +} from '../jobs/ai/create_digest' import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' import { getDigest } from '../services/digest' import { findActiveUser } from '../services/user' @@ -32,6 +35,13 @@ const isFeedback = (data: any): data is Feedback => { ) } +interface CreateDigestRequest { + voices?: string[] + language?: string + rate?: string + schedule?: CreateDigestJobSchedule +} + export function digestRouter() { const router = express.Router() @@ -72,10 +82,16 @@ export function digestRouter() { return res.sendStatus(202) } + const data = req.body as CreateDigestRequest + // enqueue job and return job id - const result = await enqueueCreateDigest({ - userId, - }) + const result = await enqueueCreateDigest( + { + userId, + ...data, + }, + data.schedule + ) // return job id return res.status(201).send(result) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 2149fade9..85f239f1f 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -5,11 +5,9 @@ import { TaskState } from '../generated/graphql' interface Chapter { title: string -} - -interface LibraryItem { id: string url: string + wordCount: number thumbnail?: string } @@ -26,7 +24,6 @@ export interface Digest { urlsToAudio?: string[] speechFiles?: SpeechFile[] - libraryItems?: LibraryItem[] } const digestKey = (userId: string) => `digest:${userId}` diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index cb835da13..3c6b7ccf9 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -19,6 +19,7 @@ import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize' import { CreateDigestJobData, CreateDigestJobResponse, + CreateDigestJobSchedule, CREATE_DIGEST_JOB, } from '../jobs/ai/create_digest' import { BulkActionData, BULK_ACTION_JOB_NAME } from '../jobs/bulk_action' @@ -859,7 +860,8 @@ export const enqueueSendEmail = async (jobData: SendEmailJobData) => { } export const enqueueCreateDigest = async ( - data: CreateDigestJobData + data: CreateDigestJobData, + schedule?: CreateDigestJobSchedule ): Promise => { const queue = await getBackendQueue() if (!queue) { @@ -873,6 +875,12 @@ export const enqueueCreateDigest = async ( removeOnFail: true, attempts: 3, priority: getJobPriority(CREATE_DIGEST_JOB), + repeat: schedule + ? { + immediately: true, // run immediately + pattern: schedule === 'daily' ? '0 0 * * *' : '0 0 * * 7', // every day at midnight or every Sunday at midnight + } + : undefined, }) logger.info('create digest job enqueued', { jobId: job.id }) From b2a55a78d3a5aeed7039a459c52491075cbe6275 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 12:53:29 +0800 Subject: [PATCH 26/33] check if feature is granted --- packages/api/src/routers/digest_router.ts | 28 +++++++++++++++++++++++ packages/api/src/services/features.ts | 4 ++++ 2 files changed, 32 insertions(+) diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index c415ea6c8..0bf492d0a 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -7,6 +7,7 @@ import { } from '../jobs/ai/create_digest' import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' import { getDigest } from '../services/digest' +import { FeatureName, findGrantedFeatureByName } from '../services/features' import { findActiveUser } from '../services/user' import { analytics } from '../utils/analytics' import { getClaimsByToken, getTokenByRequest } from '../utils/auth' @@ -72,6 +73,15 @@ export function digestRouter() { return res.sendStatus(401) } + const feature = await findGrantedFeatureByName( + FeatureName.AIDigest, + userId + ) + if (!feature) { + logger.info(`${FeatureName.AIDigest} not granted: ${userId}`) + return res.sendStatus(403) + } + // check if job is already in queue // if yes then return 202 accepted // else enqueue job @@ -128,6 +138,15 @@ export function digestRouter() { return res.sendStatus(401) } + const feature = await findGrantedFeatureByName( + FeatureName.AIDigest, + userId + ) + if (!feature) { + logger.info(`${FeatureName.AIDigest} not granted: ${userId}`) + return res.sendStatus(403) + } + // get job by user id const jobId = createJobId(CREATE_DIGEST_JOB, userId) const job = await getJob(jobId) @@ -185,6 +204,15 @@ export function digestRouter() { return res.sendStatus(401) } + const feature = await findGrantedFeatureByName( + FeatureName.AIDigest, + userId + ) + if (!feature) { + logger.info(`${FeatureName.AIDigest} not granted: ${userId}`) + return res.sendStatus(403) + } + // get feedback from request body if (!isFeedback(req.body)) { logger.info('Invalid feedback format') diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index 664461a50..d22e65c74 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -9,12 +9,14 @@ import { logger } from '../utils/logger' const MAX_ULTRA_REALISTIC_USERS = 1500 const MAX_YOUTUBE_TRANSCRIPT_USERS = 500 const MAX_NOTION_USERS = 1000 +const MAX_AIDIGEST_USERS = 5 export enum FeatureName { AISummaries = 'ai-summaries', YouTubeTranscripts = 'youtube-transcripts', UltraRealisticVoice = 'ultra-realistic-voice', Notion = 'notion', + AIDigest = 'ai-digest', } export const getFeatureName = (name: string): FeatureName | undefined => { @@ -40,6 +42,8 @@ export const optInFeature = async ( ) case FeatureName.Notion: return optInLimitedFeature(FeatureName.Notion, uid, MAX_NOTION_USERS) + case FeatureName.AIDigest: + return optInLimitedFeature(FeatureName.AIDigest, uid, MAX_AIDIGEST_USERS) default: return undefined } From 448e4328073e60b56fdbaeb99537e6fd392b31f8 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 13:03:48 +0800 Subject: [PATCH 27/33] runs at 1pm pst --- packages/api/src/utils/createTask.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 3c6b7ccf9..70306ff76 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -878,7 +878,8 @@ export const enqueueCreateDigest = async ( repeat: schedule ? { immediately: true, // run immediately - pattern: schedule === 'daily' ? '0 0 * * *' : '0 0 * * 7', // every day at midnight or every Sunday at midnight + pattern: schedule === 'daily' ? '0 13 * * *' : '0 13 * * 7', // every day or every Sunday at 1PM + utc: true, } : undefined, }) From 6222d567a4444fe9e556f8dcb88e1e0d3f7e0daf Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 14:32:07 +0800 Subject: [PATCH 28/33] add byline to the digest --- packages/api/src/jobs/ai/create_digest.ts | 12 ++++++++---- packages/api/src/services/digest.ts | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index fa7a975ff..e424b679c 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -353,15 +353,18 @@ const filterSummaries = (summaries: RankedItem[]): RankedItem[] => { } // TODO: we can use something more sophisticated to generate titles -const generateTitle = (selections: RankedItem[]): string => +const generateTitle = (summaries: RankedItem[]): string => 'Omnivore digest: ' + - selections.map((item) => item.libraryItem.title).join(',') + summaries.map((item) => item.libraryItem.title).join(',') // TODO: generate description based on the summaries -const generateDescription = (selections: RankedItem[]): string => 'description' +const generateDescription = (summaries: RankedItem[]): string => 'description' // TODO: generate content based on the summaries -const generateContent = (selections: RankedItem[]): string => 'content' +const generateContent = (summaries: RankedItem[]): string => 'content' + +const generateByline = (summaries: RankedItem[]): string => + summaries.map((item) => item.libraryItem.author).join(',') export const createDigestJob = async (jobData: CreateDigestJobData) => { digestDefinition = await fetchDigestDefinition() @@ -397,6 +400,7 @@ export const createDigestJob = async (jobData: CreateDigestJobData) => { })), createdAt: new Date(), description: generateDescription(summaries), + byline: generateByline(summaries), } await writeDigest(jobData.userId, digest) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 85f239f1f..24be606cc 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -16,6 +16,7 @@ export interface Digest { jobState: TaskState createdAt: Date description: string + byline: string url?: string title?: string From af184ded22e80608416d437f65eee90a0baa1851 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 15:36:16 +0800 Subject: [PATCH 29/33] dedupe repeated jobs --- packages/api/src/jobs/ai/create_digest.ts | 4 +- packages/api/src/routers/digest_router.ts | 46 ++++++++++------------- packages/api/src/services/digest.ts | 6 +-- packages/api/src/utils/createTask.ts | 18 +++++++-- 4 files changed, 40 insertions(+), 34 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index e424b679c..f5d636709 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -22,6 +22,7 @@ import { TaskState } from '../../generated/graphql' export type CreateDigestJobSchedule = 'daily' | 'weekly' export interface CreateDigestJobData { + id: string userId: string voices?: string[] language?: string @@ -30,6 +31,7 @@ export interface CreateDigestJobData { export interface CreateDigestJobResponse { jobId: string + jobState: TaskState } interface Selector { query: string @@ -385,7 +387,7 @@ export const createDigestJob = async (jobData: CreateDigestJobData) => { }) const title = generateTitle(summaries) const digest: Digest = { - id: uuid(), + id: jobData.id, title, content: generateContent(summaries), urlsToAudio: [], diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 0bf492d0a..24f2bb103 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -1,11 +1,8 @@ import cors from 'cors' import express from 'express' import { env } from '../env' -import { - CreateDigestJobSchedule, - CREATE_DIGEST_JOB, -} from '../jobs/ai/create_digest' -import { createJobId, getJob, jobStateToTaskState } from '../queue-processor' +import { TaskState } from '../generated/graphql' +import { CreateDigestJobSchedule } from '../jobs/ai/create_digest' import { getDigest } from '../services/digest' import { FeatureName, findGrantedFeatureByName } from '../services/features' import { findActiveUser } from '../services/user' @@ -14,6 +11,7 @@ import { getClaimsByToken, getTokenByRequest } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' import { enqueueCreateDigest } from '../utils/createTask' import { logger } from '../utils/logger' +import { v4 as uuid } from 'uuid' interface Feedback { digestRating: number @@ -82,21 +80,21 @@ export function digestRouter() { return res.sendStatus(403) } - // check if job is already in queue + const data = req.body as CreateDigestRequest + + // check if job is running // if yes then return 202 accepted // else enqueue job - const jobId = createJobId(CREATE_DIGEST_JOB, userId) - const existingJob = await getJob(jobId) - if (existingJob) { - logger.info(`Job already in queue: ${jobId}`) + const digest = await getDigest(userId) + if (digest?.jobState === TaskState.Running) { + logger.info(`Digest job is running: ${userId}`) return res.sendStatus(202) } - const data = req.body as CreateDigestRequest - // enqueue job and return job id const result = await enqueueCreateDigest( { + id: uuid(), // generate job id userId, ...data, }, @@ -147,26 +145,22 @@ export function digestRouter() { return res.sendStatus(403) } - // get job by user id - const jobId = createJobId(CREATE_DIGEST_JOB, userId) - const job = await getJob(jobId) - if (job) { - // if job is in queue then return job state - const jobState = await job.getState() - return res.send({ - jobId: job.id, - jobState: jobStateToTaskState(jobState), - }) - } - - // if job is done and removed then get the digest from redis + // get the digest from redis const digest = await getDigest(userId) if (!digest) { logger.info(`Digest not found: ${userId}`) return res.sendStatus(404) } - // return digest + if (digest.jobState === TaskState.Running) { + // if job is running then return job state + return res.send({ + jobId: digest.id, + jobState: digest.jobState, + }) + } + + // if job is done then return the digest return res.send(digest) } catch (error) { logger.error('Error while getting digest', error) diff --git a/packages/api/src/services/digest.ts b/packages/api/src/services/digest.ts index 24be606cc..811202efd 100644 --- a/packages/api/src/services/digest.ts +++ b/packages/api/src/services/digest.ts @@ -14,10 +14,10 @@ interface Chapter { export interface Digest { id: string jobState: TaskState - createdAt: Date - description: string - byline: string + createdAt?: Date + description?: string + byline?: string url?: string title?: string content?: string diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 70306ff76..eeefd3692 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -14,6 +14,7 @@ import { env } from '../env' import { ArticleSavingRequestStatus, CreateLabelInput, + TaskState, } from '../generated/graphql' import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize' import { @@ -50,7 +51,7 @@ import { UPDATE_HIGHLIGHT_JOB, UPDATE_LABELS_JOB, } from '../jobs/update_db' -import { createJobId, getBackendQueue, JOB_VERSION } from '../queue-processor' +import { getBackendQueue, JOB_VERSION } from '../queue-processor' import { redisDataSource } from '../redis_data_source' import { signFeatureToken } from '../services/features' import { OmnivoreAuthorizationHeader } from './auth' @@ -58,6 +59,7 @@ import { CreateTaskError } from './errors' import { stringToHash } from './helpers' import { logger } from './logger' import View = google.cloud.tasks.v2.Task.View +import { writeDigest } from '../services/digest' // Instantiates a client. const client = new CloudTasksClient() @@ -868,9 +870,8 @@ export const enqueueCreateDigest = async ( throw new Error('No queue found') } - const jobId = createJobId(CREATE_DIGEST_JOB, data.userId) const job = await queue.add(CREATE_DIGEST_JOB, data, { - jobId, // dedupe by userId + jobId: data.id, // dedupe by job id removeOnComplete: true, removeOnFail: true, attempts: 3, @@ -886,8 +887,17 @@ export const enqueueCreateDigest = async ( logger.info('create digest job enqueued', { jobId: job.id }) + const digest = { + id: data.id, + jobState: TaskState.Running, + } + + // update digest job state in redis + await writeDigest(data.userId, digest) + return { - jobId, + jobId: digest.id, + jobState: digest.jobState, } } From 57cb8978390fe4f26e777e7a356fb784aac8a02f Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 15:45:05 +0800 Subject: [PATCH 30/33] generate content --- packages/api/src/jobs/ai/create_digest.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index f5d636709..8646cb405 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -362,11 +362,17 @@ const generateTitle = (summaries: RankedItem[]): string => // TODO: generate description based on the summaries const generateDescription = (summaries: RankedItem[]): string => 'description' -// TODO: generate content based on the summaries -const generateContent = (summaries: RankedItem[]): string => 'content' +// generate content based on the summaries +const generateContent = (summaries: RankedItem[]): string => + summaries + .map((summary) => `## ${summary.libraryItem.title}\n ${summary.summary}`) + .join('\n\n') const generateByline = (summaries: RankedItem[]): string => - summaries.map((item) => item.libraryItem.author).join(',') + summaries + .filter((summary) => !!summary.libraryItem.author) + .map((item) => item.libraryItem.author) + .join(',') export const createDigestJob = async (jobData: CreateDigestJobData) => { digestDefinition = await fetchDigestDefinition() From aeda4f5d5d6558ded75476910b1aabd68531a8eb Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 16:20:00 +0800 Subject: [PATCH 31/33] add libraryItemIds to the payload --- packages/api/src/jobs/ai/create_digest.ts | 15 +++++++++++-- packages/api/src/routers/digest_router.ts | 1 + packages/api/src/services/library_item.ts | 27 +++++++++++++---------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index 8646cb405..41aa5b7ec 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -10,7 +10,10 @@ import { SSMLOptions, } from '@omnivore/text-to-speech-handler' import axios from 'axios' -import { searchLibraryItems } from '../../services/library_item' +import { + findLibraryItemsByIds, + searchLibraryItems, +} from '../../services/library_item' import { redisDataSource } from '../../redis_data_source' import { htmlToMarkdown } from '../../utils/parser' import yaml from 'yaml' @@ -27,6 +30,7 @@ export interface CreateDigestJobData { voices?: string[] language?: string rate?: string + libraryItemIds?: string[] } export interface CreateDigestJobResponse { @@ -125,7 +129,10 @@ const getPreferencesList = async (userId: string): Promise => { } // Makes multiple DB queries and combines the results -const getCandidatesList = async (userId: string): Promise => { +const getCandidatesList = async ( + userId: string, + libraryItemIds?: string[] +): Promise => { // use the queries from the digest definitions to lookup preferences // There should be a list of multiple queries we use. For now we can // hardcode these queries: @@ -133,6 +140,10 @@ const getCandidatesList = async (userId: string): Promise => { // count: 100 // reason: "most recent 100 items saved over 500 words + if (libraryItemIds) { + return findLibraryItemsByIds(libraryItemIds, userId) + } + const candidates = await Promise.all( digestDefinition.candidateSelectors.map(async (selector) => { // use the selector to fetch items diff --git a/packages/api/src/routers/digest_router.ts b/packages/api/src/routers/digest_router.ts index 24f2bb103..2cc8649c7 100644 --- a/packages/api/src/routers/digest_router.ts +++ b/packages/api/src/routers/digest_router.ts @@ -39,6 +39,7 @@ interface CreateDigestRequest { language?: string rate?: string schedule?: CreateDigestJobSchedule + libraryItemIds?: string[] } export function digestRouter() { diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 9adcde7b0..647b248db 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -21,6 +21,7 @@ import { redisDataSource } from '../redis_data_source' import { authTrx, getColumns, + getColumnsDbName, getRepository, queryBuilderToRawSql, } from '../repository' @@ -625,13 +626,11 @@ export const buildQuery = ( userId: string ) => { // select all columns except content - const selects: Select[] = getColumns(libraryItemRepository) - .map((column) => ({ column: `library_item.${column}` })) + const selects: Select[] = getColumnsDbName(libraryItemRepository) .filter( - (select) => - select.column !== 'library_item.readableContent' && - select.column !== 'library_item.originalContent' + (select) => select !== 'readable_content' && select !== 'original_content' ) + .map((column) => ({ column: `library_item.${column}` })) const parameters: ObjectLiteral[] = [] const orders: Sort[] = [] @@ -652,10 +651,18 @@ export const buildQuery = ( queryBuilder.where('library_item.user_id = :userId', { userId }) // add select - selects.forEach((select) => { + selects.forEach((select, index) => { + if (index === 0) { + queryBuilder.select(select.column, select.alias) + } + queryBuilder.addSelect(select.column, select.alias) }) + if (args.includeContent) { + queryBuilder.addSelect('library_item.readable_content') + } + if (!args.includePending) { queryBuilder.andWhere("library_item.state <> 'PROCESSING'") } @@ -754,18 +761,14 @@ export const findRecentLibraryItems = async ( } export const findLibraryItemsByIds = async (ids: string[], userId: string) => { - const selectColumns = getColumns(libraryItemRepository) - .filter( - (column) => column !== 'readableContent' && column !== 'originalContent' - ) + const selectColumns = getColumnsDbName(libraryItemRepository) + .filter((column) => column !== 'original_content') .map((column) => `library_item.${column}`) return authTrx( async (tx) => tx .createQueryBuilder(LibraryItem, 'library_item') .select(selectColumns) - .leftJoinAndSelect('library_item.labels', 'labels') - .leftJoinAndSelect('library_item.highlights', 'highlights') .where('library_item.id IN (:...ids)', { ids }) .getMany(), undefined, From 0eb6460becfc9bdf05b11ca218cecf719214b680 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 16:35:29 +0800 Subject: [PATCH 32/33] fix tests --- packages/api/src/jobs/ai/create_digest.ts | 17 +++++++++++------ packages/api/src/services/library_item.ts | 11 +++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index 41aa5b7ec..686efefd2 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -359,19 +359,24 @@ const generateSpeechFiles = ( return speechFiles } -// TODO: we should have a QA step here that does some +// we should have a QA step here that does some // basic checks to make sure the summaries are good. const filterSummaries = (summaries: RankedItem[]): RankedItem[] => { return summaries.filter((item) => item.summary.length > 100) } -// TODO: we can use something more sophisticated to generate titles +// we can use something more sophisticated to generate titles const generateTitle = (summaries: RankedItem[]): string => 'Omnivore digest: ' + - summaries.map((item) => item.libraryItem.title).join(',') + summaries.map((item) => item.libraryItem.title).join(', ') -// TODO: generate description based on the summaries -const generateDescription = (summaries: RankedItem[]): string => 'description' +// generate description based on the summaries +const generateDescription = (summaries: RankedItem[]): string => + `We selected ${ + summaries.length + } articles from your last 24 hours of saved items, covering ${summaries + .map((summary) => summary.topic) + .join(', ')}.` // generate content based on the summaries const generateContent = (summaries: RankedItem[]): string => @@ -383,7 +388,7 @@ const generateByline = (summaries: RankedItem[]): string => summaries .filter((summary) => !!summary.libraryItem.author) .map((item) => item.libraryItem.author) - .join(',') + .join(', ') export const createDigestJob = async (jobData: CreateDigestJobData) => { digestDefinition = await fetchDigestDefinition() diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 647b248db..832aea3fa 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -21,7 +21,6 @@ import { redisDataSource } from '../redis_data_source' import { authTrx, getColumns, - getColumnsDbName, getRepository, queryBuilderToRawSql, } from '../repository' @@ -626,9 +625,9 @@ export const buildQuery = ( userId: string ) => { // select all columns except content - const selects: Select[] = getColumnsDbName(libraryItemRepository) + const selects: Select[] = getColumns(libraryItemRepository) .filter( - (select) => select !== 'readable_content' && select !== 'original_content' + (select) => select !== 'readableContent' && select !== 'originalContent' ) .map((column) => ({ column: `library_item.${column}` })) @@ -660,7 +659,7 @@ export const buildQuery = ( }) if (args.includeContent) { - queryBuilder.addSelect('library_item.readable_content') + queryBuilder.addSelect('library_item.readableContent') } if (!args.includePending) { @@ -761,8 +760,8 @@ export const findRecentLibraryItems = async ( } export const findLibraryItemsByIds = async (ids: string[], userId: string) => { - const selectColumns = getColumnsDbName(libraryItemRepository) - .filter((column) => column !== 'original_content') + const selectColumns = getColumns(libraryItemRepository) + .filter((column) => column !== 'originalContent') .map((column) => `library_item.${column}`) return authTrx( async (tx) => From 3e51aa7c03c6cd8368867b8dd311e35403a5fb1b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 17 Apr 2024 16:49:19 +0800 Subject: [PATCH 33/33] generate description --- packages/api/src/jobs/ai/create_digest.ts | 26 +++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts index 686efefd2..33012507f 100644 --- a/packages/api/src/jobs/ai/create_digest.ts +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -141,6 +141,7 @@ const getCandidatesList = async ( // reason: "most recent 100 items saved over 500 words if (libraryItemIds) { + logger.info('Using libraryItemIds') return findLibraryItemsByIds(libraryItemIds, userId) } @@ -299,7 +300,7 @@ const chooseRankedSelections = (rankedCandidates: RankedItem[]) => { logger.info('finalSelections: ', finalSelections) - return finalSelections + return { finalSelections, rankedTopics } } const summarizeItems = async ( @@ -371,12 +372,15 @@ const generateTitle = (summaries: RankedItem[]): string => summaries.map((item) => item.libraryItem.title).join(', ') // generate description based on the summaries -const generateDescription = (summaries: RankedItem[]): string => +const generateDescription = ( + summaries: RankedItem[], + rankedTopics: string[] +): string => `We selected ${ summaries.length - } articles from your last 24 hours of saved items, covering ${summaries - .map((summary) => summary.topic) - .join(', ')}.` + } articles from your last 24 hours of saved items, covering ${rankedTopics.join( + ', ' + )}.` // generate content based on the summaries const generateContent = (summaries: RankedItem[]): string => @@ -393,12 +397,16 @@ const generateByline = (summaries: RankedItem[]): string => export const createDigestJob = async (jobData: CreateDigestJobData) => { digestDefinition = await fetchDigestDefinition() - const candidates = await getCandidatesList(jobData.userId) + const candidates = await getCandidatesList( + jobData.userId, + jobData.libraryItemIds + ) const userProfile = await findOrCreateUserProfile(jobData.userId) const rankedCandidates = await rankCandidates(candidates, userProfile) - const selections = chooseRankedSelections(rankedCandidates) + const { finalSelections, rankedTopics } = + chooseRankedSelections(rankedCandidates) - const summaries = await summarizeItems(selections) + const summaries = await summarizeItems(finalSelections) const filteredSummaries = filterSummaries(summaries) @@ -423,7 +431,7 @@ export const createDigestJob = async (jobData: CreateDigestJobData) => { wordCount: speechFiles[index].wordCount, })), createdAt: new Date(), - description: generateDescription(summaries), + description: generateDescription(summaries, rankedTopics), byline: generateByline(summaries), }