diff --git a/packages/api/src/services/integrations/integration.ts b/packages/api/src/services/integrations/integration.ts index 13b61e82a..44c95e680 100644 --- a/packages/api/src/services/integrations/integration.ts +++ b/packages/api/src/services/integrations/integration.ts @@ -20,7 +20,7 @@ export interface RetrieveRequest { export interface IntegrationClient { name: string - _token: string + token: string accessToken(): Promise diff --git a/packages/api/src/services/integrations/notion.ts b/packages/api/src/services/integrations/notion.ts index 4a21d5606..a4e8a84e5 100644 --- a/packages/api/src/services/integrations/notion.ts +++ b/packages/api/src/services/integrations/notion.ts @@ -5,6 +5,7 @@ import { Integration } from '../../entity/integration' import { LibraryItem } from '../../entity/library_item' import { env } from '../../env' import { Merge } from '../../util' +import { highlightUrl } from '../../utils/helpers' import { logger } from '../../utils/logger' import { IntegrationClient } from './integration' @@ -61,10 +62,10 @@ interface NotionPage { }> } 'Original URL': { - url: string | null + url: string } 'Omnivore URL': { - url: string | null + url: string } Tags?: { multi_select: Array<{ name: string }> @@ -91,35 +92,39 @@ interface NotionPage { }> } +type Property = 'highlights' | 'labels' | 'notes' + interface Settings { parentPageId: string parentDatabaseId: string + properties: Property[] } export class NotionClient implements IntegrationClient { name = 'NOTION' - _headers = { + token: string + + private headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'Notion-Version': '2022-06-28', } - _timeout = 5000 // 5 seconds - _axios = axios.create({ + private timeout = 5000 // 5 seconds + private axiosInstance = axios.create({ baseURL: 'https://api.notion.com/v1', - timeout: this._timeout, + timeout: this.timeout, }) - _token: string - _client: Client - _integrationData?: Merge + private client: Client + private integrationData?: Merge constructor(token: string, integration?: Integration) { - this._token = token - this._client = new Client({ + this.token = token + this.client = new Client({ auth: token, - timeoutMs: this._timeout, + timeoutMs: this.timeout, }) - this._integrationData = integration + this.integrationData = integration } accessToken = async (): Promise => { @@ -129,16 +134,16 @@ export class NotionClient implements IntegrationClient { `${env.notion.clientId}:${env.notion.clientSecret}` ).toString('base64') - const response = await this._axios.post<{ access_token: string }>( + const response = await this.axiosInstance.post<{ access_token: string }>( '/oauth/token', { grant_type: 'authorization_code', - code: this._token, + code: this.token, redirect_uri: `${env.client.url}/settings/integrations`, }, { headers: { - ...this._headers, + ...this.headers, Authorization: `Basic ${encoded}`, }, } @@ -158,15 +163,13 @@ export class NotionClient implements IntegrationClient { return Promise.resolve(env.notion.authUrl) } - private _itemToNotionPage = (item: LibraryItem): NotionPage => { - const databaseId = this._integrationData?.settings?.parentDatabaseId - if (!databaseId) { - throw new Error('Notion database id not found') - } - + private itemToNotionPage = ( + item: LibraryItem, + settings: Settings + ): NotionPage => { return { parent: { - database_id: databaseId, + database_id: settings.parentDatabaseId, }, icon: item.siteIcon ? { @@ -207,9 +210,14 @@ export class NotionClient implements IntegrationClient { 'Omnivore URL': { url: `${env.client.url}/me/${item.slug}`, }, - Tags: item.labels - ? { multi_select: item.labels.map((label) => ({ name: label.name })) } - : undefined, + Tags: + item.labels && settings.properties.includes('labels') + ? { + multi_select: item.labels.map((label) => ({ + name: label.name, + })), + } + : undefined, }, children: item.highlights ? item.highlights.map((highlight) => ({ @@ -218,7 +226,12 @@ export class NotionClient implements IntegrationClient { rich_text: [ { text: { - content: highlight.quote || '', + content: settings.properties.includes('highlights') + ? highlight.quote || '' + : '', + link: { + url: highlightUrl(item.slug, highlight.id), + }, }, annotations: { color: highlight.color as AnnotationColor, @@ -226,7 +239,9 @@ export class NotionClient implements IntegrationClient { }, { text: { - content: `\n${highlight.annotation || ''}`, + content: settings.properties.includes('notes') + ? `\n${highlight.annotation || ''}` + : '', }, annotations: { italic: true, @@ -239,26 +254,45 @@ export class NotionClient implements IntegrationClient { } } - _createPage = async (page: NotionPage) => { - await this._client.pages.create(page) + private createPage = async (page: NotionPage) => { + await this.client.pages.create(page) + } + + private findPage = async (url: string, databaseId: string) => { + const response = await this.client.databases.query({ + database_id: databaseId, + page_size: 1, + filter: { + property: 'Omnivore URL', + url: { + equals: url, + }, + }, + }) + if (response.results.length > 0) { + return response.results[0] + } + + return null } export = async (items: LibraryItem[]): Promise => { - if (!this._integrationData || !this._integrationData.settings) { + const settings = this.integrationData?.settings + if (!this.integrationData || !settings) { logger.error('Notion integration data not found') return false } - const pageId = this._integrationData.settings.parentPageId + const pageId = settings.parentPageId if (!pageId) { logger.error('Notion parent page id not found') return false } - const databaseId = this._integrationData.settings.parentDatabaseId + let databaseId = settings.parentDatabaseId if (!databaseId) { // create a database for the items - const database = await this._client.databases.create({ + const database = await this.client.databases.create({ parent: { page_id: pageId, }, @@ -296,18 +330,67 @@ export class NotionClient implements IntegrationClient { }) // save the database id - this._integrationData.settings.parentDatabaseId = database.id + databaseId = database.id await updateIntegration( - this._integrationData.id, + this.integrationData.id, { - settings: this._integrationData.settings, + settings: { + ...this.integrationData.settings, + parentDatabaseId: databaseId, + }, }, - this._integrationData.user.id + this.integrationData.user.id ) } - const pages = items.map(this._itemToNotionPage) - await Promise.all(pages.map((page) => this._createPage(page))) + await Promise.all( + items.map(async (item) => { + const notionPage = this.itemToNotionPage(item, settings) + const url = notionPage.properties['Omnivore URL'].url + + const existingPage = await this.findPage(url, databaseId) + if (existingPage) { + // update the page + await this.client.pages.update({ + page_id: existingPage.id, + properties: notionPage.properties, + }) + + const children = notionPage.children + if (children) { + // get the existing children + const response = await this.client.blocks.children.list({ + block_id: existingPage.id, + }) + if (response.results.length > 0) { + const existingChildren = + response.results as NotionPage['children'] + // delete the existing children from children + notionPage.children = children.filter( + (child) => + !existingChildren?.some( + (existingChild) => + existingChild.paragraph.rich_text[0].text.link?.url === + child.paragraph.rich_text[0].text.link?.url + ) + ) + } + // append the children + if (notionPage.children && notionPage.children.length > 0) { + await this.client.blocks.children.append({ + block_id: existingPage.id, + children: notionPage.children, + }) + } + } + + return + } + + // create the page + return this.createPage(notionPage) + }) + ) return true } diff --git a/packages/api/src/services/integrations/pocket.ts b/packages/api/src/services/integrations/pocket.ts index 20f4c212c..9945c7a6e 100644 --- a/packages/api/src/services/integrations/pocket.ts +++ b/packages/api/src/services/integrations/pocket.ts @@ -5,7 +5,7 @@ import { IntegrationClient } from './integration' export class PocketClient implements IntegrationClient { name = 'POCKET' - _token: string + token: string _axios = axios.create({ baseURL: 'https://getpocket.com/v3', headers: { @@ -16,7 +16,7 @@ export class PocketClient implements IntegrationClient { }) constructor(token: string) { - this._token = token + this.token = token } accessToken = async (): Promise => { @@ -25,7 +25,7 @@ export class PocketClient implements IntegrationClient { '/oauth/authorize', { consumer_key: env.pocket.consumerKey, - code: this._token, + code: this.token, } ) return response.data.access_token diff --git a/packages/api/src/services/integrations/readwise.ts b/packages/api/src/services/integrations/readwise.ts index 25a61f438..dfc5b43db 100644 --- a/packages/api/src/services/integrations/readwise.ts +++ b/packages/api/src/services/integrations/readwise.ts @@ -33,6 +33,8 @@ interface ReadwiseHighlight { export class ReadwiseClient implements IntegrationClient { name = 'READWISE' + token: string + _headers = { 'Content-Type': 'application/json', } @@ -40,10 +42,9 @@ export class ReadwiseClient implements IntegrationClient { baseURL: 'https://readwise.io/api/v2', timeout: 5000, // 5 seconds }) - _token: string constructor(token: string) { - this._token = token + this.token = token } accessToken = async (): Promise => { @@ -51,10 +52,10 @@ export class ReadwiseClient implements IntegrationClient { const response = await this._axios.get('/auth', { headers: { ...this._headers, - Authorization: `Token ${this._token}`, + Authorization: `Token ${this.token}`, }, }) - return response.status === 204 ? this._token : null + return response.status === 204 ? this.token : null } catch (error) { if (axios.isAxiosError(error)) { logger.error(error.response) @@ -121,7 +122,7 @@ export class ReadwiseClient implements IntegrationClient { { headers: { ...this._headers, - Authorization: `Token ${this._token}`, + Authorization: `Token ${this.token}`, }, } ) diff --git a/packages/web/pages/settings/integrations.tsx b/packages/web/pages/settings/integrations.tsx index c9f2e3296..39af9ec23 100644 --- a/packages/web/pages/settings/integrations.tsx +++ b/packages/web/pages/settings/integrations.tsx @@ -176,7 +176,7 @@ export default function Integrations(): JSX.Element { token, name: 'NOTION', type: 'EXPORT', - enabled: true, + enabled: false, }) showSuccessToast('Connected with Notion.') diff --git a/packages/web/pages/settings/integrations/notion.tsx b/packages/web/pages/settings/integrations/notion.tsx index eb56fa892..dce6e0a24 100644 --- a/packages/web/pages/settings/integrations/notion.tsx +++ b/packages/web/pages/settings/integrations/notion.tsx @@ -12,7 +12,7 @@ import 'antd/dist/antd.compact.css' import { CheckboxValueType } from 'antd/lib/checkbox/Group' import Image from 'next/image' import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' +import { useEffect, useMemo } from 'react' import { HStack, VStack } from '../../../components/elements/LayoutPrimitives' import { PageMetaData } from '../../../components/patterns/PageMetaData' import { Beta } from '../../../components/templates/Beta' @@ -20,17 +20,14 @@ import { Header } from '../../../components/templates/settings/SettingsTable' import { SettingsLayout } from '../../../components/templates/SettingsLayout' import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation' import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation' -import { - Integration, - useGetIntegrationsQuery, -} from '../../../lib/networking/queries/useGetIntegrationsQuery' +import { useGetIntegrationsQuery } from '../../../lib/networking/queries/useGetIntegrationsQuery' import { applyStoredTheme } from '../../../lib/themeUpdater' import { showSuccessToast } from '../../../lib/toastHelpers' type FieldType = { parentPageId?: string parentDatabaseId?: string - autoSync?: boolean + enabled: boolean properties?: string[] } @@ -39,27 +36,21 @@ export default function Notion(): JSX.Element { const router = useRouter() const { integrations, revalidate } = useGetIntegrationsQuery() - const [notion, setNotion] = useState() + const notion = useMemo(() => { + return integrations.find((i) => i.name == 'NOTION' && i.type == 'EXPORT') + }, [integrations]) const [form] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() useEffect(() => { - const notion = integrations.find( - (i) => i.name == 'NOTION' && i.type == 'EXPORT' - ) - - if (notion) { - setNotion(notion) - - form.setFieldsValue({ - parentPageId: notion.settings?.parentPageId, - parentDatabaseId: notion.settings?.parentDatabaseId, - autoSync: notion.settings?.autoSync, - properties: notion.settings?.properties, - }) - } - }, [form, integrations]) + form.setFieldsValue({ + parentPageId: notion?.settings?.parentPageId, + parentDatabaseId: notion?.settings?.parentDatabaseId, + enabled: notion?.enabled, + properties: notion?.settings?.properties, + }) + }, [form, notion]) const deleteNotion = async () => { if (!notion) { @@ -69,6 +60,7 @@ export default function Notion(): JSX.Element { await deleteIntegrationMutation(notion.id) showSuccessToast('Notion integration disconnected successfully.') + revalidate() router.push('/settings/integrations') } @@ -82,7 +74,7 @@ export default function Notion(): JSX.Element { name: notion.name, type: notion.type, token: notion.token, - enabled: notion.enabled, + enabled: values.enabled, settings: values, }) } @@ -170,7 +162,7 @@ export default function Notion(): JSX.Element { label="Automatic Sync" - name="autoSync" + name="enabled" valuePropName="checked" >