diff --git a/packages/api/package.json b/packages/api/package.json index 111fc6502..b387616f4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -24,6 +24,7 @@ "@google-cloud/tasks": "^4.0.0", "@graphql-tools/utils": "^9.1.1", "@langchain/openai": "^0.0.14", + "@notionhq/client": "^2.2.14", "@omnivore/content-handler": "1.0.0", "@omnivore/liqe": "1.0.0", "@omnivore/readability": "1.0.0", diff --git a/packages/api/src/jobs/integration/export_item.ts b/packages/api/src/jobs/integration/export_item.ts index 327f16206..a2a0f1456 100644 --- a/packages/api/src/jobs/integration/export_item.ts +++ b/packages/api/src/jobs/integration/export_item.ts @@ -44,9 +44,9 @@ export const exportItem = async (jobData: ExportItemJobData) => { } logger.info('exporting item...', logObject) - const client = getIntegrationClient(integration.name) + const client = getIntegrationClient(integration.name, integration.token) - const synced = await client.export(integration.token, libraryItems) + const synced = await client.export(libraryItems) if (!synced) { logger.error('failed to export item', logObject) return false diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index 90c118129..5148d0893 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -69,9 +69,9 @@ export const setIntegrationResolver = authorized< integrationToSave.taskName = existingIntegration.taskName } else { // Create - const integrationService = getIntegrationClient(input.name) + const integrationService = getIntegrationClient(input.name, input.token) // authorize and get access token - const token = await integrationService.accessToken(input.token) + const token = await integrationService.accessToken() if (!token) { return { errorCodes: [SetIntegrationErrorCode.InvalidToken], diff --git a/packages/api/src/routers/integration_router.ts b/packages/api/src/routers/integration_router.ts index d9a89f43a..7bf5cbdc6 100644 --- a/packages/api/src/routers/integration_router.ts +++ b/packages/api/src/routers/integration_router.ts @@ -21,7 +21,7 @@ export function integrationRouter() { return res.status(401).send('UNAUTHORIZED') } - const integrationClient = getIntegrationClient(req.params.name) + const integrationClient = getIntegrationClient(req.params.name, '') // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const state = req.body.state as string diff --git a/packages/api/src/services/integrations/index.ts b/packages/api/src/services/integrations/index.ts index 435ca98e2..953199052 100644 --- a/packages/api/src/services/integrations/index.ts +++ b/packages/api/src/services/integrations/index.ts @@ -6,20 +6,20 @@ import { NotionClient } from './notion' import { PocketClient } from './pocket' import { ReadwiseClient } from './readwise' -const integrations: IntegrationClient[] = [ - new ReadwiseClient(), - new PocketClient(), - new NotionClient(), -] - -export const getIntegrationClient = (name: string): IntegrationClient => { - const service = integrations.find( - (s) => s.name.toLowerCase() === name.toLowerCase() - ) - if (!service) { - throw new Error(`Integration client not found: ${name}`) +export const getIntegrationClient = ( + name: string, + token: string +): IntegrationClient => { + switch (name.toLowerCase()) { + case 'readwise': + return new ReadwiseClient(token) + case 'pocket': + return new PocketClient(token) + case 'notion': + return new NotionClient(token) + default: + throw new Error(`Integration client not found: ${name}`) } - return service } export const deleteIntegrations = async ( diff --git a/packages/api/src/services/integrations/integration.ts b/packages/api/src/services/integrations/integration.ts index f5183f097..13b61e82a 100644 --- a/packages/api/src/services/integrations/integration.ts +++ b/packages/api/src/services/integrations/integration.ts @@ -20,11 +20,11 @@ export interface RetrieveRequest { export interface IntegrationClient { name: string - apiUrl: string + _token: string - accessToken(token: string): Promise + accessToken(): Promise auth(state: string): Promise - export(token: string, items: LibraryItem[]): Promise + export(items: LibraryItem[]): Promise } diff --git a/packages/api/src/services/integrations/notion.ts b/packages/api/src/services/integrations/notion.ts index bcd6573e0..539c1a3ef 100644 --- a/packages/api/src/services/integrations/notion.ts +++ b/packages/api/src/services/integrations/notion.ts @@ -1,3 +1,4 @@ +import { Client } from '@notionhq/client' import axios from 'axios' import { LibraryItem } from '../../entity/library_item' import { env } from '../../env' @@ -6,7 +7,7 @@ import { IntegrationClient } from './integration' interface NotionPage { parent: { - database_id: string + page_id: string } cover?: { external: { @@ -14,55 +15,56 @@ interface NotionPage { } } properties: { - Name: { - title: Array<{ - text: { - content: string - } - }> - } - URL: { - url: string - } - Tags: { - multi_select: Array<{ - name: string - }> - } + title: Array<{ + text: { + content: string + } + }> } } export class NotionClient implements IntegrationClient { name = 'NOTION' - apiUrl = 'https://api.notion.com/v1' - headers = { + _headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'Notion-Version': '2022-06-28', } - timeout = 5000 // 5 seconds + _timeout = 5000 // 5 seconds + _axios = axios.create({ + baseURL: 'https://api.notion.com/v1', + timeout: this._timeout, + }) + _token: string + _client: Client - accessToken = async (code: string): Promise => { - const authUrl = `${this.apiUrl}/oauth/token` + constructor(token: string) { + this._token = token + this._client = new Client({ + auth: token, + timeoutMs: this._timeout, + }) + } + + accessToken = async (): Promise => { try { // encode in base 64 const encoded = Buffer.from( `${env.notion.clientId}:${env.notion.clientSecret}` ).toString('base64') - const response = await axios.post<{ access_token: string }>( - authUrl, + const response = await this._axios.post<{ access_token: string }>( + '/oauth/token', { grant_type: 'authorization_code', - code, + code: this._token, redirect_uri: `${env.client.url}/settings/integrations`, }, { headers: { - authorization: `Basic ${encoded}`, - ...this.headers, + ...this._headers, + Authorization: `Basic ${encoded}`, }, - timeout: this.timeout, } ) return response.data.access_token @@ -83,7 +85,7 @@ export class NotionClient implements IntegrationClient { private _itemToNotionPage = (item: LibraryItem): NotionPage => { return { parent: { - database_id: item.id, + page_id: '83a3f627ab9e44ac83fe657141aec615', }, cover: item.thumbnail ? { @@ -93,49 +95,28 @@ export class NotionClient implements IntegrationClient { } : undefined, properties: { - Name: { - title: [ - { - text: { - content: item.title, - }, + title: [ + { + text: { + content: item.title, }, - ], - }, - URL: { - url: item.originalUrl, - }, - Tags: { - multi_select: - item.labels?.map((label) => { - return { - name: label.name, - } - }) || [], - }, + }, + ], }, } } - export = async (token: string, items: LibraryItem[]): Promise => { - const url = `${this.apiUrl}/pages` - const page = this._itemToNotionPage(items[0]) - try { - const response = await axios.post(url, page, { - headers: { - Authorization: `Bearer ${token}`, - ...this.headers, - }, - timeout: this.timeout, - }) - return response.status === 200 - } catch (error) { - if (axios.isAxiosError(error)) { - logger.error(error.response) - } else { - logger.error(error) - } - return false - } + _createPage = async (page: NotionPage) => { + await this._client.pages.create(page) + } + + export = async (items: LibraryItem[]): Promise => { + // find/create a parent page for all the items + const parentPageName = 'Omnivore' + + const pages = items.map(this._itemToNotionPage) + await Promise.all(pages.map((page) => this._createPage(page))) + + return true } } diff --git a/packages/api/src/services/integrations/pocket.ts b/packages/api/src/services/integrations/pocket.ts index 8f9e8ed50..20f4c212c 100644 --- a/packages/api/src/services/integrations/pocket.ts +++ b/packages/api/src/services/integrations/pocket.ts @@ -5,24 +5,27 @@ import { IntegrationClient } from './integration' export class PocketClient implements IntegrationClient { name = 'POCKET' - apiUrl = 'https://getpocket.com/v3' - headers = { - 'Content-Type': 'application/json', - 'X-Accept': 'application/json', + _token: string + _axios = axios.create({ + baseURL: 'https://getpocket.com/v3', + headers: { + 'Content-Type': 'application/json', + 'X-Accept': 'application/json', + }, + timeout: 5000, // 5 seconds + }) + + constructor(token: string) { + this._token = token } - accessToken = async (token: string): Promise => { - const url = `${this.apiUrl}/oauth/authorize` + accessToken = async (): Promise => { try { - const response = await axios.post<{ access_token: string }>( - url, + const response = await this._axios.post<{ access_token: string }>( + '/oauth/authorize', { consumer_key: env.pocket.consumerKey, - code: token, - }, - { - headers: this.headers, - timeout: 5000, // 5 seconds + code: this._token, } ) return response.data.access_token @@ -45,15 +48,11 @@ export class PocketClient implements IntegrationClient { const redirectUri = `${env.client.url}/settings/integrations` // make a POST request to Pocket to get a request token - const response = await axios.post<{ code: string }>( - `${this.apiUrl}/oauth/request`, + const response = await this._axios.post<{ code: string }>( + '/oauth/request', { consumer_key: consumerKey, redirect_uri: redirectUri, - }, - { - headers: this.headers, - timeout: 5000, // 5 seconds } ) const { code } = response.data diff --git a/packages/api/src/services/integrations/readwise.ts b/packages/api/src/services/integrations/readwise.ts index 84b15a25a..25a61f438 100644 --- a/packages/api/src/services/integrations/readwise.ts +++ b/packages/api/src/services/integrations/readwise.ts @@ -33,17 +33,28 @@ interface ReadwiseHighlight { export class ReadwiseClient implements IntegrationClient { name = 'READWISE' - apiUrl = 'https://readwise.io/api/v2' + _headers = { + 'Content-Type': 'application/json', + } + _axios = axios.create({ + baseURL: 'https://readwise.io/api/v2', + timeout: 5000, // 5 seconds + }) + _token: string - accessToken = async (token: string): Promise => { - const authUrl = `${this.apiUrl}/auth` + constructor(token: string) { + this._token = token + } + + accessToken = async (): Promise => { try { - const response = await axios.get(authUrl, { + const response = await this._axios.get('/auth', { headers: { - Authorization: `Token ${token}`, + ...this._headers, + Authorization: `Token ${this._token}`, }, }) - return response.status === 204 ? token : null + return response.status === 204 ? this._token : null } catch (error) { if (axios.isAxiosError(error)) { logger.error(error.response) @@ -54,14 +65,14 @@ export class ReadwiseClient implements IntegrationClient { } } - export = async (token: string, items: LibraryItem[]): Promise => { + export = async (items: LibraryItem[]): Promise => { let result = true const highlights = items.flatMap(this._itemToReadwiseHighlight) // If there are no highlights, we will skip the sync if (highlights.length > 0) { - result = await this._syncWithReadwise(token, highlights) + result = await this._syncWithReadwise(highlights) } return result @@ -100,21 +111,18 @@ export class ReadwiseClient implements IntegrationClient { } private _syncWithReadwise = async ( - token: string, highlights: ReadwiseHighlight[] ): Promise => { - const url = `${this.apiUrl}/highlights` - const response = await axios.post( - url, + const response = await this._axios.post( + '/highlights', { highlights, }, { headers: { - Authorization: `Token ${token}`, - 'Content-Type': 'application/json', + ...this._headers, + Authorization: `Token ${this._token}`, }, - timeout: 5000, // 5 seconds } ) return response.status === 200 diff --git a/yarn.lock b/yarn.lock index 8631bcdbc..cb71dd339 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3931,6 +3931,14 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@notionhq/client@^2.2.14": + version "2.2.14" + resolved "https://registry.yarnpkg.com/@notionhq/client/-/client-2.2.14.tgz#6807ec27ee89584529abfd28d058b2661f828b74" + integrity sha512-oqUefZtCiJPCX+74A1Os9OVTef3fSnVWe2eVQtU1HJSD+nsfxfhwvDKnzJTh2Tw1ZHKLxpieHB/nzGdY+Uo12A== + dependencies: + "@types/node-fetch" "^2.5.10" + node-fetch "^2.6.1" + "@npmcli/arborist@^5.6.3": version "5.6.3" resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-5.6.3.tgz#40810080272e097b4a7a4f56108f4a31638a9874" @@ -7877,6 +7885,14 @@ dependencies: "@types/node" "*" +"@types/node-fetch@^2.5.10", "@types/node-fetch@^2.6.4": + version "2.6.11" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" + integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node-fetch@^2.5.7": version "2.6.1" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975" @@ -7885,14 +7901,6 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node-fetch@^2.6.4": - version "2.6.11" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" - integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== - dependencies: - "@types/node" "*" - form-data "^4.0.0" - "@types/node-fetch@^2.6.6": version "2.6.7" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.7.tgz#a1abe2ce24228b58ad97f99480fdcf9bbc6ab16d"