diff --git a/extensions/notion/CHANGELOG.md b/extensions/notion/CHANGELOG.md index 4ef2f1bb5085..f765eddb0b17 100644 --- a/extensions/notion/CHANGELOG.md +++ b/extensions/notion/CHANGELOG.md @@ -1,5 +1,8 @@ # Notion Changelog +## [Multiple Notion accounts support] - {PR_MERGE_DATE} +- Add support for up to two Notion accounts + ## [Use Clipboard in Create + Update Shortcuts] - 2025-12-01 - New `Preference` allowing to use Clipboard for auto-filling **Name (Title)** or **Content** (ref: [Issue #23086](https://github.com/raycast/extensions/issues/23086)) diff --git a/extensions/notion/README.md b/extensions/notion/README.md index eb62c1ec29ba..fb3c002de413 100644 --- a/extensions/notion/README.md +++ b/extensions/notion/README.md @@ -15,6 +15,19 @@ If you are not logging in through OAuth, you can still use the extension with an 3. Manually give the integration access to the specific pages or databases by [adding connections to them](https://www.notion.so/help/add-and-manage-connections-with-the-api#add-connections-to-pages) +## Using multiple accounts + +You can connect two Notion accounts when using OAuth: + +1. In Raycast settings, set `Authentication Type` to `OAuth (Recommended)`. +2. Fill both `Account 1 Label` and `Account 2 Label` (for example: Work, Personal). +3. Use the account selector in Create Database Page, Quick Capture, and Add Text to Page to switch accounts. +4. Search Notion will query both accounts and show the account label on the right. + +Note: Internal Integration Secret mode is single-account only. + +When using `@notion`, you can specify the account with the label (for example: “in Work”) and it will route to that account. If the account is ambiguous and multiple accounts are configured, it will ask which account to use. + ## I can't find the Notion page or database from Raycast If you have connected your Notion account to Raycast, you need to grant the Raycast Extension access to new root pages. diff --git a/extensions/notion/package.json b/extensions/notion/package.json index b0861c147e2c..1ee95dbe83d7 100644 --- a/extensions/notion/package.json +++ b/extensions/notion/package.json @@ -157,7 +157,7 @@ } ], "ai": { - "instructions": "-If asked to summarize or view a document, look for pages with the title of the document.\\n - When referring to a page or database, always include a link to the page or database. \\n - When creating a page, always include the database name. Ask for the database name if it's not provided.", + "instructions": "-If asked to summarize or view a document, look for pages with the title of the document.\\n - When referring to a page or database, always include a link to the page or database. \\n - When creating a page, always include the database name. Ask for the database name if it's not provided.\\n - If multiple Notion accounts are configured, infer the account from the user's request and the account labels in settings (e.g., Work/Personal).\\n - When calling tools, pass the matching accountLabel.\\n - If the account isn't clear, ask the user which account to use before calling tools.", "evals": [ { "input": "@notion Summarize the last marketing meeting notes", @@ -251,12 +251,48 @@ ] }, "preferences": [ + { + "name": "notion_auth_type", + "type": "dropdown", + "title": "Authentication Type", + "required": true, + "default": "oauth", + "data": [ + { + "title": "OAuth (Recommended)", + "value": "oauth" + }, + { + "title": "Internal Integration Secret", + "value": "internal" + } + ], + "description": "OAuth supports multiple accounts. Internal integrations use a single account." + }, + { + "name": "notion_account_1_label", + "type": "textfield", + "title": "Account 1 Label", + "required": false, + "default": "", + "placeholder": "Work", + "description": "Label shown in account selectors and search results. Fill both account labels to enable switching." + }, + { + "name": "notion_account_2_label", + "type": "textfield", + "title": "Account 2 Label", + "required": false, + "default": "", + "placeholder": "Personal", + "description": "Leave empty to use a single account. Fill both account labels to enable switching." + }, { "name": "notion_token", "type": "password", "title": "Internal Integration Secret", "required": false, - "description": "In Notion, go to Settings & members > My connections > Develop or manage integrations > New integration", + "description": "Only used with Authentication Type = Internal Integration Secret. Create an integration at notion.so/my-integrations, copy the Internal Integration Secret under Secrets, and add the integration as a connection to the pages or databases you want to access.", "placeholder": "ntn_FGDeSrZNodQuaJEqWzvxcTyrPlpBHYvdLpTZykrWEu" }, { diff --git a/extensions/notion/src/add-text-to-page.tsx b/extensions/notion/src/add-text-to-page.tsx index 36257ea9e3bb..c5cb9ea63c12 100644 --- a/extensions/notion/src/add-text-to-page.tsx +++ b/extensions/notion/src/add-text-to-page.tsx @@ -1,12 +1,18 @@ import { BlockObjectRequest } from "@notionhq/client/build/src/api-endpoints"; import { Action, ActionPanel, Form, Icon, LaunchProps, Toast, closeMainWindow, showToast } from "@raycast/api"; -import { FormValidation, useForm, withAccessToken } from "@raycast/utils"; +import { FormValidation, useForm } from "@raycast/utils"; import { markdownToBlocks } from "@tryfabric/martian"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useSearchPages } from "./hooks"; import { appendBlockToPage, getPageIcon } from "./utils/notion"; -import { notionService } from "./utils/notion/oauth"; +import { + getActiveAccountId, + getDefaultAccountId, + getNotionAccounts, + NotionAccountId, + setActiveAccountId, +} from "./utils/notion/oauth"; type AddTextToPageValues = { prepend: boolean; @@ -17,9 +23,11 @@ type AddTextToPageValues = { function AddTextToPage(props: LaunchProps<{ arguments: Arguments.AddTextToPage }>) { const [searchText, setSearchText] = useState(""); - const { data, isLoading } = useSearchPages(searchText); + const accounts = getNotionAccounts(); + const [accountId, setAccountId] = useState(getDefaultAccountId()); + const { data, isLoading } = useSearchPages(searchText, accountId); - const { itemProps, handleSubmit } = useForm({ + const { itemProps, handleSubmit, setValue } = useForm({ async onSubmit(values) { try { await showToast({ style: Toast.Style.Animated, title: "Adding content to the page" }); @@ -37,6 +45,7 @@ function AddTextToPage(props: LaunchProps<{ arguments: Arguments.AddTextToPage } children: content, prepend: values.prepend, addDateDivider: values.addDateDivider, + accountId, }); await closeMainWindow(); await showToast({ style: Toast.Style.Success, title: "Added text to page" }); @@ -55,6 +64,12 @@ function AddTextToPage(props: LaunchProps<{ arguments: Arguments.AddTextToPage } const searchPages = data?.pages.filter((page) => page.object === "page"); + useEffect(() => { + getActiveAccountId() + .then(setAccountId) + .catch(() => undefined); + }, []); + return (
} > + {accounts.length > 1 ? ( + { + const nextAccountId = value as NotionAccountId; + setAccountId(nextAccountId); + setActiveAccountId(nextAccountId); + setValue("page", ""); + }} + storeValue + > + {accounts.map((account) => ( + + ))} + + ) : null} ); } -export default withAccessToken(notionService)(AddTextToPage); +export default AddTextToPage; diff --git a/extensions/notion/src/components/DatabaseList.tsx b/extensions/notion/src/components/DatabaseList.tsx index 60e0a297cb58..f2bca4ddf779 100644 --- a/extensions/notion/src/components/DatabaseList.tsx +++ b/extensions/notion/src/components/DatabaseList.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useDatabaseProperties, useDatabasesView } from "../hooks"; import { queryDatabase, getPageName, Page, User } from "../utils/notion"; +import { NotionAccountId } from "../utils/notion/oauth"; import { DatabaseView } from "./DatabaseView"; import { CreatePageForm } from "./forms"; @@ -11,12 +12,13 @@ import { CreatePageForm } from "./forms"; type DatabaseListProps = { databasePage: Page; setRecentPage: (page: Page) => Promise; - removeRecentPage: (id: string) => Promise; + removeRecentPage: (id: string, accountId?: NotionAccountId) => Promise; users?: User[]; }; export function DatabaseList({ databasePage, setRecentPage, removeRecentPage, users }: DatabaseListProps) { const databaseId = databasePage.id; + const accountId = databasePage.accountId; const databaseName = getPageName(databasePage); const [searchText, setSearchText] = useState(); const [sort, setSort] = useState<"last_edited_time" | "created_time">("last_edited_time"); @@ -25,10 +27,14 @@ export function DatabaseList({ databasePage, setRecentPage, removeRecentPage, us isLoading, mutate, } = useCachedPromise( - (databaseId, searchText, sort) => queryDatabase(databaseId, searchText, sort), - [databaseId, searchText, sort], + (databaseId, searchText, sort, accountId) => queryDatabase(databaseId, searchText, sort, accountId), + [databaseId, searchText, sort, accountId], + ); + const { data: databaseProperties, isLoading: isLoadingDatabaseProperties } = useDatabaseProperties( + databaseId, + undefined, + accountId, ); - const { data: databaseProperties, isLoading: isLoadingDatabaseProperties } = useDatabaseProperties(databaseId); const { data: databaseView, isLoading: isLoadingDatabaseViews, setDatabaseView } = useDatabasesView(databaseId); useEffect(() => { @@ -83,7 +89,7 @@ export function DatabaseList({ databasePage, setRecentPage, removeRecentPage, us title="Create New Page" icon={Icon.Plus} shortcut={Keyboard.Shortcut.Common.New} - target={} + target={} /> } diff --git a/extensions/notion/src/components/DatabaseView.tsx b/extensions/notion/src/components/DatabaseView.tsx index 6c250bafcec1..257a2ffdcd24 100644 --- a/extensions/notion/src/components/DatabaseView.tsx +++ b/extensions/notion/src/components/DatabaseView.tsx @@ -2,6 +2,7 @@ import { List, Image } from "@raycast/api"; import { JSX } from "react"; import { notionColorToTintColor, isType, Page, DatabaseProperty, PropertyConfig, User } from "../utils/notion"; +import { NotionAccountId } from "../utils/notion/oauth"; import type { DatabaseView } from "../utils/types"; import { PageListItem } from "./PageListItem"; @@ -14,7 +15,7 @@ type DatabaseViewProps = { databaseView?: DatabaseView; setDatabaseView?: (view: DatabaseView) => Promise; setRecentPage: (page: Page) => Promise; - removeRecentPage: (id: string) => Promise; + removeRecentPage: (id: string, accountId?: NotionAccountId) => Promise; mutate: () => Promise; users?: User[]; sort?: "last_edited_time" | "created_time"; @@ -182,6 +183,7 @@ export function DatabaseView(props: DatabaseViewProps) { pageId={p.id} pageProperty={p.properties[propertyId]} icon="./icon/kanban_status_started.png" + accountId={p.accountId} shortcut={{ macOS: { modifiers: ["cmd", "shift"], key: "s" }, Windows: { modifiers: ["ctrl", "shift"], key: "s" }, diff --git a/extensions/notion/src/components/PageDetail.tsx b/extensions/notion/src/components/PageDetail.tsx index 237e6005b1d3..97bcd9e8d6ec 100644 --- a/extensions/notion/src/components/PageDetail.tsx +++ b/extensions/notion/src/components/PageDetail.tsx @@ -60,8 +60,8 @@ export function PageDetail({ page, setRecentPage, users }: PageDetailProps) { const pageName = getPageName(page); const { data, isLoading, mutate } = useCachedPromise( - async (id) => { - const fetchedPageContent = await fetchPageContent(id); + async (id, accountId) => { + const fetchedPageContent = await fetchPageContent(id, accountId); const blocks = []; @@ -84,7 +84,7 @@ export function PageDetail({ page, setRecentPage, users }: PageDetailProps) { return { markdown: blocks.join("\n") }; }, - [page.id], + [page.id, page.accountId], ); useEffect(() => { diff --git a/extensions/notion/src/components/PageListItem.tsx b/extensions/notion/src/components/PageListItem.tsx index d34b750e2afe..c08abfd52a90 100644 --- a/extensions/notion/src/components/PageListItem.tsx +++ b/extensions/notion/src/components/PageListItem.tsx @@ -24,6 +24,7 @@ import { PageProperty, User, } from "../utils/notion"; +import { NotionAccountId } from "../utils/notion/oauth"; import { handleOnOpenPage } from "../utils/openPage"; import { DatabaseView } from "../utils/types"; @@ -39,11 +40,12 @@ type PageListItemProps = { databaseProperties?: DatabaseProperty[]; setDatabaseView?: (databaseView: DatabaseView) => Promise; setRecentPage: (page: Page) => Promise; - removeRecentPage: (id: string) => Promise; + removeRecentPage: (id: string, accountId?: NotionAccountId) => Promise; mutate: () => Promise; users?: User[]; icon?: Image.ImageLike; customActions?: JSX.Element[]; + accountLabel?: string; }; export function PageListItem({ @@ -57,6 +59,7 @@ export function PageListItem({ icon = getPageIcon(page), users, mutate, + accountLabel, }: PageListItemProps) { const accessories: List.Item.Accessory[] = []; @@ -90,6 +93,13 @@ export function PageListItem({ }); } + if (accountLabel) { + accessories.push({ + text: accountLabel, + tooltip: `Account: ${accountLabel}`, + }); + } + const quickEditProperties = databaseProperties?.filter((property) => ["checkbox", "status", "select", "multi_select", "status", "people"].includes(property.type), ); @@ -173,6 +183,7 @@ export function PageListItem({ pageId={page.id} pageProperty={page.properties[dp.id]} mutate={mutate} + accountId={page.accountId} /> ))} @@ -192,7 +203,7 @@ export function PageListItem({ title="Create New Page" icon={Icon.Plus} shortcut={Keyboard.Shortcut.Common.New} - target={} + target={} /> )} @@ -212,11 +223,11 @@ export function PageListItem({ }) ) { if (page.object === "database") { - deleteDatabase(page.id); + deleteDatabase(page.id, page.accountId); } else { - deletePage(page.id); + deletePage(page.id, page.accountId); } - await removeRecentPage(page.id); + await removeRecentPage(page.id, page.accountId); await mutate(); } }} diff --git a/extensions/notion/src/components/actions/ActionEditPageProperty.tsx b/extensions/notion/src/components/actions/ActionEditPageProperty.tsx index cd0272181e3c..6af8f6001e7d 100644 --- a/extensions/notion/src/components/actions/ActionEditPageProperty.tsx +++ b/extensions/notion/src/components/actions/ActionEditPageProperty.tsx @@ -11,6 +11,7 @@ import { PropertyConfig, ReadablePropertyType, } from "../../utils/notion"; +import { NotionAccountId } from "../../utils/notion/oauth"; type EditPropertyOptions = PropertyConfig<"select" | "multi_select">["options"][number] & { icon?: string; @@ -24,6 +25,7 @@ export function ActionEditPageProperty({ mutate, icon, options, + accountId, }: { databaseProperty: DatabaseProperty; pageId: string; @@ -32,10 +34,11 @@ export function ActionEditPageProperty({ shortcut?: Keyboard.Shortcut; icon?: Image.ImageLike; options?: EditPropertyOptions[]; + accountId?: NotionAccountId; }) { if (!icon) icon = getPropertyIcon(databaseProperty); - const { data: users } = useUsers(); + const { data: users } = useUsers(accountId); const title = "Set " + databaseProperty.name; @@ -44,7 +47,7 @@ export function ActionEditPageProperty({ style: Toast.Style.Animated, title: "Updating Property", }); - const updatedPage = await patchPage(pageId, patch); + const updatedPage = await patchPage(pageId, patch, accountId); if (updatedPage && updatedPage.id) { showToast({ title: "Property Updated", diff --git a/extensions/notion/src/components/forms/AppendToPageForm.tsx b/extensions/notion/src/components/forms/AppendToPageForm.tsx index 119efbb74491..211b69aed121 100644 --- a/extensions/notion/src/components/forms/AppendToPageForm.tsx +++ b/extensions/notion/src/components/forms/AppendToPageForm.tsx @@ -16,7 +16,7 @@ export function AppendToPageForm(props: { page: Page; onContentUpdate?: () => vo pop(); - await appendToPage(page.id, { content: values.content }); + await appendToPage(page.id, { content: values.content }, page.accountId); onContentUpdate?.(); await showToast({ style: Toast.Style.Success, title: "Added content to the page", message: pageTitle }); diff --git a/extensions/notion/src/components/forms/CreatePageForm.tsx b/extensions/notion/src/components/forms/CreatePageForm.tsx index 9ad956dddba8..40974063151b 100644 --- a/extensions/notion/src/components/forms/CreatePageForm.tsx +++ b/extensions/notion/src/components/forms/CreatePageForm.tsx @@ -13,7 +13,7 @@ import { Keyboard, } from "@raycast/api"; import { useForm, FormValidation, usePromise } from "@raycast/utils"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useDatabaseProperties, @@ -24,6 +24,13 @@ import { useUsers, } from "../../hooks"; import { createDatabasePage, DatabaseProperty } from "../../utils/notion"; +import { + getActiveAccountId, + getDefaultAccountId, + getNotionAccounts, + NotionAccountId, + setActiveAccountId as setStoredActiveAccountId, +} from "../../utils/notion/oauth"; import { handleOnOpenPage } from "../../utils/openPage"; import { Quicklink } from "../../utils/types"; import { ActionSetVisibleProperties } from "../actions"; @@ -41,12 +48,14 @@ export type CreatePageFormValues = { type LaunchContext = { visiblePropIds?: string[]; defaults?: CreatePageFormValues; + accountId?: NotionAccountId; }; type CreatePageFormProps = { mutate?: () => Promise; launchContext?: LaunchContext; defaults?: Partial; + accountId?: NotionAccountId; }; const createPropertyId = (property: DatabaseProperty) => "property::" + property.type + "::" + property.id; @@ -54,20 +63,24 @@ const createPropertyId = (property: DatabaseProperty) => "property::" + property const NON_EDITABLE_PROPETY_TYPES = ["formula"]; const filterNoEditableProperties = (dp: DatabaseProperty) => !NON_EDITABLE_PROPETY_TYPES.includes(dp.type); -export function CreatePageForm({ mutate, launchContext, defaults }: CreatePageFormProps) { +export function CreatePageForm({ mutate, launchContext, defaults, accountId }: CreatePageFormProps) { const preferences = getPreferenceValues(); + const accounts = getNotionAccounts(); + const lockedAccountId = accountId; const defaultValues = launchContext?.defaults ?? defaults; const initialDatabaseId = defaultValues?.database; + const initialAccountId = lockedAccountId ?? launchContext?.accountId ?? getDefaultAccountId(); const [databaseId, setDatabaseId] = useState(initialDatabaseId ? initialDatabaseId : null); - const { data: databaseProperties } = useDatabaseProperties(databaseId, filterNoEditableProperties); + const [activeAccountId, setActiveAccountId] = useState(initialAccountId); + const { data: databaseProperties } = useDatabaseProperties(databaseId, filterNoEditableProperties, activeAccountId); const { visiblePropIds, setVisiblePropIds } = useVisibleDatabasePropIds( databaseId || "__no_id__", launchContext?.visiblePropIds, ); - const { data: users } = useUsers(); - const { data: databases, isLoading: isLoadingDatabases } = useDatabases(); - const { data: relationPages, isLoading: isLoadingRelationPages } = useRelations(databaseProperties); + const { data: users } = useUsers(activeAccountId); + const { data: databases, isLoading: isLoadingDatabases } = useDatabases(activeAccountId); + const { data: relationPages, isLoading: isLoadingRelationPages } = useRelations(databaseProperties, activeAccountId); const { setRecentPage } = useRecentPages(); const databasePropertyIds = databaseProperties.map((dp) => dp.id) || []; @@ -105,6 +118,13 @@ export function CreatePageForm({ mutate, launchContext, defaults }: CreatePageFo }, ); + useEffect(() => { + if (lockedAccountId || launchContext?.accountId || initialDatabaseId) return; + getActiveAccountId() + .then(setActiveAccountId) + .catch(() => undefined); + }, [lockedAccountId, launchContext?.accountId, initialDatabaseId]); + const { itemProps, values, handleSubmit, reset, focus, setValue } = useForm({ initialValues, validation, @@ -117,10 +137,13 @@ export function CreatePageForm({ mutate, launchContext, defaults }: CreatePageFo await showToast({ style: Toast.Style.Animated, title: "Creating page" }); - const page = await createDatabasePage({ - ...initialValues, - ...pageValues, - }); + const page = await createDatabasePage( + { + ...initialValues, + ...pageValues, + }, + activeAccountId, + ); await showToast({ style: Toast.Style.Success, @@ -178,7 +201,11 @@ export function CreatePageForm({ mutate, launchContext, defaults }: CreatePageFo function getQuicklink(): Quicklink { const url = "raycast://extensions/HenriChabrand/notion/create-database-page"; - const launchContext: LaunchContext = { defaults: values, visiblePropIds: visiblePropIds ?? databasePropertyIds }; + const launchContext: LaunchContext = { + defaults: values, + visiblePropIds: visiblePropIds ?? databasePropertyIds, + accountId: activeAccountId, + }; let name: string | undefined; const databaseTitle = databases.find((d) => d.id == databaseId)?.title; if (databaseTitle) name = "Create new page in " + databaseTitle; @@ -250,6 +277,27 @@ export function CreatePageForm({ mutate, launchContext, defaults }: CreatePageFo } > + {accounts.length > 1 && !lockedAccountId ? ( + { + const nextAccountId = value as NotionAccountId; + setActiveAccountId(nextAccountId); + setStoredActiveAccountId(nextAccountId); + if (!initialDatabaseId) { + setDatabaseId(null); + setValue("database", undefined); + } + }} + storeValue + > + {accounts.map((account) => ( + + ))} + + ) : null} {initialDatabaseId ? null : ( <> fetchUsers()); +export function useUsers(accountId?: NotionAccountId, options?: { enabled?: boolean }) { + const { enabled = true } = options ?? {}; + const value = useCachedPromise((id: NotionAccountId | undefined) => fetchUsers(id), [accountId], { + execute: enabled, + }); return { ...value, data: value.data ?? [] }; } -export function useRelations(properties: DatabaseProperty[]) { +export function useRelations(properties: DatabaseProperty[], accountId?: NotionAccountId) { return useCachedPromise( - async (properties: DatabaseProperty[]) => { + async (properties: DatabaseProperty[], accountId?: NotionAccountId) => { const relationPages: Record = {}; await Promise.all( @@ -32,7 +36,7 @@ export function useRelations(properties: DatabaseProperty[]) { if (!isType(property, "relation")) return null; const relationId = property.config.database_id; if (!relationId) return null; - const pages = await queryDatabase(relationId, undefined); + const pages = await queryDatabase(relationId, undefined, "last_edited_time", accountId); relationPages[relationId] = pages; return pages; }), @@ -40,26 +44,30 @@ export function useRelations(properties: DatabaseProperty[]) { return relationPages; }, - [properties], + [properties, accountId], ); } -export function useDatabases() { - const value = useCachedPromise(() => fetchDatabases()); +export function useDatabases(accountId?: NotionAccountId) { + const value = useCachedPromise((id: NotionAccountId | undefined) => fetchDatabases(id), [accountId]); return { ...value, data: value.data ?? [] }; } -export function useDatabaseProperties(databaseId: string | null, filter?: (value: DatabaseProperty) => boolean) { +export function useDatabaseProperties( + databaseId: string | null, + filter?: (value: DatabaseProperty) => boolean, + accountId?: NotionAccountId, +) { const value = useCachedPromise( - (id): Promise => - fetchDatabaseProperties(id).then((databaseProperties) => { + (id, accountId): Promise => + fetchDatabaseProperties(id, accountId).then((databaseProperties) => { if (databaseProperties && filter) { return databaseProperties.filter(filter); } return databaseProperties; }), - [databaseId], + [databaseId, accountId], { execute: !!databaseId }, ); @@ -112,11 +120,13 @@ export class RecentPage { id: string; last_visited_time: number; type: Page["object"]; + accountId?: NotionAccountId; - constructor(page: Page) { + constructor(page: Page, fallbackAccountId?: NotionAccountId) { this.id = page.id; this.last_visited_time = Date.now(); this.type = page.object; + this.accountId = page.accountId ?? fallbackAccountId; } updateLastVisitedTime() { @@ -127,6 +137,7 @@ export class RecentPage { export function useRecentPages() { const { data, isLoading, mutate } = useCachedPromise(async () => { let data = await LocalStorage.getItem("RECENT_PAGES"); + const defaultAccountId = getDefaultAccountId(); // try migrating the old recently opened pages to the new format if (!data || typeof data !== "string") { @@ -137,7 +148,7 @@ export function useRecentPages() { const oldRecentPages = JSON.parse(oldData) as Page[]; - data = JSON.stringify(oldRecentPages.map((p) => new RecentPage(p))); + data = JSON.stringify(oldRecentPages.map((p) => new RecentPage(p, defaultAccountId))); // save the new data await LocalStorage.setItem("RECENT_PAGES", data); @@ -145,7 +156,12 @@ export function useRecentPages() { await LocalStorage.removeItem("RECENTLY_OPENED_PAGES"); } - const recentPages = JSON.parse(data) as RecentPage[]; + const recentPages = (JSON.parse(data) as Array).map((page) => { + if ("object" in page && !("type" in page)) { + return new RecentPage(page, defaultAccountId); + } + return { ...page, accountId: page.accountId ?? defaultAccountId } as RecentPage; + }); // for each RecentPage object, turn it into a Page object, and filter out any undefined values const recentPagesWithContent: Page[] = ( @@ -153,10 +169,11 @@ export function useRecentPages() { recentPages.map((p) => { // convert each RecentPage object into a Page object // don't error if the page is not found + const accountId = p.accountId ?? defaultAccountId; if (p.type === "page") { - return fetchPage(p.id, true); + return fetchPage(p.id, true, accountId); } else { - return fetchDatabase(p.id, true); + return fetchDatabase(p.id, true, accountId); } }), ) @@ -168,17 +185,20 @@ export function useRecentPages() { async function setRecentPage(page: Page) { if (!data) return; - let recentPages = [...data].map((p) => new RecentPage(p)); + const resolvedAccountId = page.accountId ?? (await getActiveAccountId()); + let recentPages = [...data].map((p) => new RecentPage(p, p.accountId ?? resolvedAccountId)); // check if the page is already in the recent pages - const cachedPageIndex = data.findIndex((x) => x.id === page.id); + const cachedPageIndex = data.findIndex( + (x) => x.id === page.id && (x.accountId ?? resolvedAccountId) === resolvedAccountId, + ); if (cachedPageIndex > -1) { // if the page is already in the recent pages, update the last visited time recentPages[cachedPageIndex].updateLastVisitedTime(); } else { // otherwise, add the page to the recent pages - recentPages.push(new RecentPage(page)); + recentPages.push(new RecentPage({ ...page, accountId: resolvedAccountId })); } // sort by last visited time @@ -193,13 +213,20 @@ export function useRecentPages() { mutate(); } - async function removeRecentPage(id: string) { + async function removeRecentPage(id: string, accountId?: NotionAccountId) { if (!data) return; // remove the page from the recent pages - const updatedPages = data.filter((page) => page.id !== id); + const updatedPages = data.filter((page) => { + if (!accountId) return page.id !== id; + return page.id !== id || page.accountId !== accountId; + }); - await LocalStorage.setItem("RECENT_PAGES", JSON.stringify(updatedPages)); + const defaultAccountId = getDefaultAccountId(); + await LocalStorage.setItem( + "RECENT_PAGES", + JSON.stringify(updatedPages.map((page) => new RecentPage(page, page.accountId ?? defaultAccountId))), + ); mutate(); } @@ -212,8 +239,8 @@ export function useRecentPages() { }; } -export function useSearchPages(query: string) { - return useCachedPromise(search, [query], { +export function useSearchPages(query: string, accountId?: NotionAccountId) { + return useCachedPromise((searchText, accountId) => search(searchText, undefined, 25, accountId), [query, accountId], { keepPreviousData: true, }); } diff --git a/extensions/notion/src/quick-capture.tsx b/extensions/notion/src/quick-capture.tsx index 893dd253e309..92a27e3b82aa 100644 --- a/extensions/notion/src/quick-capture.tsx +++ b/extensions/notion/src/quick-capture.tsx @@ -11,7 +11,7 @@ import { AI, Icon, } from "@raycast/api"; -import { useForm, withAccessToken } from "@raycast/utils"; +import { useForm } from "@raycast/utils"; import { parseHTML } from "linkedom"; import { useState, useEffect } from "react"; @@ -26,7 +26,13 @@ import { Page, PageContent, } from "./utils/notion"; -import { notionService } from "./utils/notion/oauth"; +import { + getActiveAccountId, + getDefaultAccountId, + getNotionAccounts, + NotionAccountId, + setActiveAccountId, +} from "./utils/notion/oauth"; import { Quicklink } from "./utils/types"; type QuickCaptureFormValues = { @@ -40,6 +46,7 @@ type LaunchContext = { captureAs?: string; pageId?: string; objectType?: Page["object"]; + accountId?: NotionAccountId; }; }; @@ -85,8 +92,11 @@ ${content} function QuickCapture({ launchContext }: QuickCaptureProps) { const [searchText, setSearchText] = useState(""); + const accounts = getNotionAccounts(); + const initialAccountId = launchContext?.defaults?.accountId ?? getDefaultAccountId(); + const [accountId, setAccountId] = useState(initialAccountId); - const { data, isLoading } = useSearchPages(searchText); + const { data, isLoading } = useSearchPages(searchText, accountId); const searchPages = data?.pages; @@ -134,8 +144,12 @@ function QuickCapture({ launchContext }: QuickCaptureProps) { let selectedPage: Page | undefined; if (launchContext?.defaults?.pageId) { - const { pageId, objectType = "page" } = launchContext.defaults; - selectedPage = objectType === "page" ? await fetchPage(pageId) : await fetchDatabase(pageId); + const { pageId, objectType = "page", accountId: contextAccountId } = launchContext.defaults; + const targetAccountId = contextAccountId ?? accountId; + selectedPage = + objectType === "page" + ? await fetchPage(pageId, true, targetAccountId) + : await fetchDatabase(pageId, true, targetAccountId); } else { selectedPage = searchPages?.find((page) => page.id === values.page); } @@ -146,15 +160,18 @@ function QuickCapture({ launchContext }: QuickCaptureProps) { } if (selectedPage.object === "page") { - await appendToPage(selectedPage.id, { content }); + await appendToPage(selectedPage.id, { content }, accountId); } if (selectedPage.object === "database") { - await createDatabasePage({ - database: selectedPage.id, - content, - "property::title::title": pageDetail?.title, - }); + await createDatabasePage( + { + database: selectedPage.id, + content, + "property::title::title": pageDetail?.title, + }, + accountId, + ); } await showToast({ style: Toast.Style.Success, title: "Captured content to page" }); @@ -196,6 +213,13 @@ function QuickCapture({ launchContext }: QuickCaptureProps) { getText(); }, []); + useEffect(() => { + if (launchContext?.defaults?.accountId) return; + getActiveAccountId() + .then(setAccountId) + .catch(() => undefined); + }, [launchContext?.defaults?.accountId]); + function getQuicklink(): Quicklink { const url = "raycast://extensions/notion/notion/quick-capture"; const page = searchPages?.find((page) => page.id === itemProps.page.value); @@ -204,6 +228,7 @@ function QuickCapture({ launchContext }: QuickCaptureProps) { captureAs: itemProps.captureAs.value, pageId: page?.id, objectType: page?.object, + accountId, }, }; @@ -234,6 +259,25 @@ function QuickCapture({ launchContext }: QuickCaptureProps) { + {accounts.length > 1 && !launchContext?.defaults?.pageId ? ( + { + const nextAccountId = value as NotionAccountId; + setAccountId(nextAccountId); + setActiveAccountId(nextAccountId); + setValue("page", ""); + }} + storeValue + > + {accounts.map((account) => ( + + ))} + + ) : null} + {/* When a default page/database is specified in the LaunchContext, we will fetch it directly instead of adding an option for it in the dropdown @@ -262,4 +306,4 @@ function QuickCapture({ launchContext }: QuickCaptureProps) { ); } -export default withAccessToken(notionService)(QuickCapture); +export default QuickCapture; diff --git a/extensions/notion/src/search-page.tsx b/extensions/notion/src/search-page.tsx index 1dc2cdfafd03..ec69f52aacd0 100644 --- a/extensions/notion/src/search-page.tsx +++ b/extensions/notion/src/search-page.tsx @@ -1,30 +1,71 @@ import { List } from "@raycast/api"; -import { useCachedPromise, withAccessToken } from "@raycast/utils"; +import { useCachedPromise } from "@raycast/utils"; import { useState } from "react"; import { PageListItem } from "./components"; import { useRecentPages, useUsers } from "./hooks"; import { search } from "./utils/notion"; -import { notionService } from "./utils/notion/oauth"; +import { getNotionAccounts, getNotionAccountLabel } from "./utils/notion/oauth"; function Search() { const { data: recentPages, setRecentPage, removeRecentPage } = useRecentPages(); const [searchText, setSearchText] = useState(""); + const accounts = getNotionAccounts(); + const primaryAccount = accounts[0]; + const secondaryAccount = accounts[1]; + const hasMultipleAccounts = accounts.length > 1; const { data, isLoading, pagination, mutate } = useCachedPromise( - (searchText: string) => + (searchText: string, accountKeys: string) => async ({ cursor }) => { - const { pages, hasMore, nextCursor } = await search(searchText, cursor); + void accountKeys; + const cursorState = (() => { + if (!cursor) return {}; + if (typeof cursor !== "string") return {}; + try { + return JSON.parse(cursor) as Record; + } catch { + return {}; + } + })(); + + const responses = await Promise.all( + accounts.map(async (account) => { + const result = await search(searchText, cursorState[account.id] ?? undefined, 25, account.id); + return { accountId: account.id, result }; + }), + ); + + const pages = responses.flatMap((response) => response.result.pages); + pages.sort((a, b) => (b.last_edited_time ?? 0) - (a.last_edited_time ?? 0)); + + const hasMore = responses.some((response) => response.result.hasMore); + const nextCursor = hasMore + ? JSON.stringify( + responses.reduce>((acc, response) => { + acc[response.accountId] = response.result.nextCursor ?? null; + return acc; + }, {}), + ) + : undefined; + return { data: pages, hasMore, cursor: nextCursor }; }, - [searchText], + [searchText, accounts.map((account) => account.id).join(",")], ); - const { data: users } = useUsers(); + const { data: primaryUsers } = useUsers(primaryAccount?.id); + const { data: secondaryUsers } = useUsers(secondaryAccount?.id, { enabled: !!secondaryAccount }); const sections = [ { title: "Recent", pages: recentPages ?? [] }, - { title: "Search", pages: data?.filter((p) => !recentPages?.some((q) => p.id == q.id)) ?? [] }, + { + title: "Search", + pages: + data?.filter( + (p) => !recentPages?.some((q) => p.id === q.id && (p.accountId ?? primaryAccount?.id) === q.accountId), + ) ?? [], + }, ]; return ( @@ -40,14 +81,18 @@ function Search() { return ( {section.pages.map((p) => { + const accountId = p.accountId ?? primaryAccount?.id; + const users = accountId === secondaryAccount?.id ? secondaryUsers : primaryUsers; + const accountLabel = hasMultipleAccounts ? getNotionAccountLabel(accountId) : undefined; return ( removeRecentPage(id, accountId)} + accountLabel={accountLabel} /> ); })} @@ -59,4 +104,4 @@ function Search() { ); } -export default withAccessToken(notionService)(Search); +export default Search; diff --git a/extensions/notion/src/tools/add-to-page.ts b/extensions/notion/src/tools/add-to-page.ts index 88b27962a2da..60401e2d5f3e 100644 --- a/extensions/notion/src/tools/add-to-page.ts +++ b/extensions/notion/src/tools/add-to-page.ts @@ -1,19 +1,20 @@ -import { withAccessToken } from "@raycast/utils"; - import { appendToPage } from "../utils/notion"; -import { notionService } from "../utils/notion/oauth"; +import { resolveAccountIdForTool } from "../utils/notion/oauth"; type Input = { /** The ID of the page to append the content to. */ pageId: string; /** The content in markdown format to append to the page. */ content: string; + /** Optional account label (for example: Work or Personal) */ + accountLabel?: string; }; -export default withAccessToken(notionService)(async ({ pageId, content }: Input) => { - const result = await appendToPage(pageId, { content }); +export default async function addToPage({ pageId, content, accountLabel }: Input) { + const accountId = resolveAccountIdForTool(accountLabel); + const result = await appendToPage(pageId, { content }, accountId); return result; -}); +} export function confirmation(params: Input) { return { diff --git a/extensions/notion/src/tools/create-page.ts b/extensions/notion/src/tools/create-page.ts index 9ddd5f4a2e0f..2685e8950ced 100644 --- a/extensions/notion/src/tools/create-page.ts +++ b/extensions/notion/src/tools/create-page.ts @@ -1,7 +1,5 @@ -import { withAccessToken } from "@raycast/utils"; - import { createDatabasePage } from "../utils/notion"; -import { getNotionClient, notionService } from "../utils/notion/oauth"; +import { getNotionClient, resolveAccountIdForTool } from "../utils/notion/oauth"; type Input = { /** The database id to create the page in. */ @@ -18,19 +16,26 @@ type Input = { Please note that HTML tags and thematic breaks are not supported in Notion due to its limitations.*/ content: string; + /** Optional account label (for example: Work or Personal) */ + accountLabel?: string; }; -export default withAccessToken(notionService)(async ({ databaseId, title, content }: Input) => { - const result = await createDatabasePage({ - database: databaseId, - "property::title::title": title, - content, - }); +export default async function createPage({ databaseId, title, content, accountLabel }: Input) { + const accountId = resolveAccountIdForTool(accountLabel); + const result = await createDatabasePage( + { + database: databaseId, + "property::title::title": title, + content, + }, + accountId, + ); return result; -}); +} -export const confirmation = withAccessToken(notionService)(async (params: Input) => { - const notion = getNotionClient(); +export async function confirmation(params: Input) { + const accountId = resolveAccountIdForTool(params.accountLabel); + const notion = await getNotionClient(accountId); const database = await notion.databases.retrieve({ database_id: params.databaseId }); let databaseName = params.databaseId; @@ -46,4 +51,4 @@ export const confirmation = withAccessToken(notionService)(async (params: Input) { name: "In database", value: databaseName }, ], }; -}); +} diff --git a/extensions/notion/src/tools/get-databases.ts b/extensions/notion/src/tools/get-databases.ts index baa84ba49aa4..dad02bf570f4 100644 --- a/extensions/notion/src/tools/get-databases.ts +++ b/extensions/notion/src/tools/get-databases.ts @@ -1,9 +1,13 @@ -import { withAccessToken } from "@raycast/utils"; - import { fetchDatabases } from "../utils/notion/database"; -import { notionService } from "../utils/notion/oauth"; +import { resolveAccountIdForTool } from "../utils/notion/oauth"; + +type Input = { + /** Optional account label (for example: Work or Personal) */ + accountLabel?: string; +}; -export default withAccessToken(notionService)(async () => { - const databases = await fetchDatabases(); +export default async function getDatabases({ accountLabel }: Input = {}) { + const accountId = resolveAccountIdForTool(accountLabel); + const databases = await fetchDatabases(accountId); return databases; -}); +} diff --git a/extensions/notion/src/tools/get-page.ts b/extensions/notion/src/tools/get-page.ts index 5f5b2b2b3212..6b697e846a0c 100644 --- a/extensions/notion/src/tools/get-page.ts +++ b/extensions/notion/src/tools/get-page.ts @@ -1,15 +1,16 @@ -import { withAccessToken } from "@raycast/utils"; - -import { getNotionClient, notionService } from "../utils/notion/oauth"; +import { getNotionClient, resolveAccountIdForTool } from "../utils/notion/oauth"; type Input = { /** The ID of the Notion page to fetch */ pageId: string; + /** Optional account label (for example: Work or Personal) */ + accountLabel?: string; }; -export default withAccessToken(notionService)(async ({ pageId }: Input) => { +export default async function getPage({ pageId, accountLabel }: Input) { try { - const notion = getNotionClient(); + const accountId = resolveAccountIdForTool(accountLabel); + const notion = await getNotionClient(accountId); const { results } = await notion.blocks.children.list({ block_id: pageId, page_size: 100, @@ -27,4 +28,4 @@ export default withAccessToken(notionService)(async ({ pageId }: Input) => { content: JSON.stringify(err), }; } -}); +} diff --git a/extensions/notion/src/tools/search-database.ts b/extensions/notion/src/tools/search-database.ts index dba90b650f68..644f604b9ac8 100644 --- a/extensions/notion/src/tools/search-database.ts +++ b/extensions/notion/src/tools/search-database.ts @@ -1,16 +1,17 @@ -import { withAccessToken } from "@raycast/utils"; - import { queryDatabase } from "../utils/notion/database"; -import { notionService } from "../utils/notion/oauth"; +import { resolveAccountIdForTool } from "../utils/notion/oauth"; type Input = { /** The ID of the database to search. */ databaseId: string; /** The query to search for. Only use plain text: it doesn't support any operators */ query: string; + /** Optional account label (for example: Work or Personal) */ + accountLabel?: string; }; -export default withAccessToken(notionService)(async ({ databaseId, query }: Input) => { - const result = await queryDatabase(databaseId, query); +export default async function searchDatabase({ databaseId, query, accountLabel }: Input) { + const accountId = resolveAccountIdForTool(accountLabel); + const result = await queryDatabase(databaseId, query, "last_edited_time", accountId); return result; -}); +} diff --git a/extensions/notion/src/tools/search-pages.ts b/extensions/notion/src/tools/search-pages.ts index 8ce731ab15c8..c3cb8098f612 100644 --- a/extensions/notion/src/tools/search-pages.ts +++ b/extensions/notion/src/tools/search-pages.ts @@ -1,23 +1,24 @@ -import { withAccessToken } from "@raycast/utils"; - import { search, Page } from "../utils/notion"; -import { notionService } from "../utils/notion/oauth"; +import { resolveAccountIdForTool } from "../utils/notion/oauth"; type cleanedPage = Pick; type Input = { /** The title of the page to search for. Only use plain text: it doesn't support any operators */ searchText: string; + /** Optional account label (for example: Work or Personal) */ + accountLabel?: string; }; -export default withAccessToken(notionService)(async ({ searchText }: Input) => { +export default async function searchPages({ searchText, accountLabel }: Input) { + const accountId = resolveAccountIdForTool(accountLabel); const allPages: cleanedPage[] = []; let hasNextPage = true; let cursor: string | undefined = undefined; const pageSize = 100; while (hasNextPage && allPages.length < 250) { - const result = await search(searchText, cursor, pageSize); + const result = await search(searchText, cursor, pageSize, accountId); allPages.push( ...result.pages.map((page) => ({ id: page.id, @@ -32,4 +33,4 @@ export default withAccessToken(notionService)(async ({ searchText }: Input) => { } return allPages; -}); +} diff --git a/extensions/notion/src/utils/notion/database/index.ts b/extensions/notion/src/utils/notion/database/index.ts index 138e809d78ce..49747ccf121b 100644 --- a/extensions/notion/src/utils/notion/database/index.ts +++ b/extensions/notion/src/utils/notion/database/index.ts @@ -5,7 +5,7 @@ import { markdownToBlocks } from "@tryfabric/martian"; import { isMarkdownPageContent, isReadableProperty } from ".."; import { handleError, isNotNullOrUndefined, pageMapper } from "../global"; -import { getNotionClient } from "../oauth"; +import { getNotionClient, NotionAccountId } from "../oauth"; import { formValueToPropertyValue } from "../page/property"; import { standardize } from "../standardize"; @@ -14,22 +14,22 @@ import { DatabaseProperty } from "./property"; export type { PropertyConfig } from "./property"; export type { DatabaseProperty }; -export async function fetchDatabase(pageId: string, silent: boolean = true) { +export async function fetchDatabase(pageId: string, silent: boolean = true, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const page = await notion.databases.retrieve({ database_id: pageId, }); - return pageMapper(page); + return pageMapper(page, accountId); } catch (err) { if (!silent) return handleError(err, "Failed to fetch database", undefined); } } -export async function fetchDatabases() { +export async function fetchDatabases(accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const databases = await notion.search({ sort: { direction: "descending", @@ -49,6 +49,7 @@ export async function fetchDatabases() { icon_emoji: x.icon?.type === "emoji" ? x.icon.emoji : null, icon_file: x.icon?.type === "file" ? x.icon.file.url : null, icon_external: x.icon?.type === "external" ? x.icon.external.url : null, + accountId, }) as Database, ); } catch (err) { @@ -56,9 +57,9 @@ export async function fetchDatabases() { } } -export async function fetchDatabaseProperties(databaseId: string) { +export async function fetchDatabaseProperties(databaseId: string, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const database = await notion.databases.retrieve({ database_id: databaseId }); const propertyNames = Object.keys(database.properties).reverse(); @@ -89,9 +90,10 @@ export async function queryDatabase( databaseId: string, query: string | undefined, sort: "last_edited_time" | "created_time" = "last_edited_time", + accountId?: NotionAccountId, ) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const database = await notion.databases.query({ database_id: databaseId, page_size: 20, @@ -115,7 +117,7 @@ export async function queryDatabase( : undefined, }); - return database.results.map(pageMapper); + return database.results.map((page) => pageMapper(page, accountId)); } catch (err) { return handleError(err, "Failed to query database", []); } @@ -123,9 +125,9 @@ export async function queryDatabase( type CreateRequest = Parameters[0]; -export async function createDatabasePage(values: Form.Values) { +export async function createDatabasePage(values: Form.Values, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const { database, content, ...props } = values; const arg: CreateRequest = { @@ -154,15 +156,15 @@ export async function createDatabasePage(values: Form.Values) { const page = await notion.pages.create(arg); - return pageMapper(page); + return pageMapper(page, accountId); } catch (err) { throw new Error("Failed to create page", { cause: err }); } } -export async function deleteDatabase(databaseId: string) { +export async function deleteDatabase(databaseId: string, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); await showToast({ style: Toast.Style.Animated, @@ -190,4 +192,5 @@ export interface Database { icon_emoji: string | null; icon_file: string | null; icon_external: string | null; + accountId?: NotionAccountId; } diff --git a/extensions/notion/src/utils/notion/global.ts b/extensions/notion/src/utils/notion/global.ts index d6bf0c003785..c4edd65c96b7 100644 --- a/extensions/notion/src/utils/notion/global.ts +++ b/extensions/notion/src/utils/notion/global.ts @@ -4,6 +4,7 @@ import { showToast, Toast } from "@raycast/api"; import { standardize } from "./standardize"; import { NotionObject, Page } from "."; +import { NotionAccountId } from "./oauth"; export function isNotNullOrUndefined(input: null | undefined | T): input is T { return input != null; @@ -25,11 +26,12 @@ export function handleError(err: unknown, title: string, returnValue: T): T { return returnValue; } -export function pageMapper(notionPage: NotionObject): Page { +export function pageMapper(notionPage: NotionObject, accountId?: NotionAccountId): Page { const page: Page = { ...notionPage, title: "Untitled", properties: {}, + accountId, created_by: "created_by" in notionPage && notionPage.created_by.object === "user" ? notionPage.created_by.id : undefined, parent_page_id: "parent" in notionPage && "page_id" in notionPage.parent ? notionPage.parent.page_id : undefined, diff --git a/extensions/notion/src/utils/notion/oauth.ts b/extensions/notion/src/utils/notion/oauth.ts index 2d1f6be58e69..abfb1a9b37fb 100644 --- a/extensions/notion/src/utils/notion/oauth.ts +++ b/extensions/notion/src/utils/notion/oauth.ts @@ -1,36 +1,152 @@ import { Client } from "@notionhq/client"; -import { OAuth, getPreferenceValues } from "@raycast/api"; +import { LocalStorage, OAuth, Toast, getPreferenceValues, showToast } from "@raycast/api"; import { OAuthService } from "@raycast/utils"; -const client = new OAuth.PKCEClient({ - redirectMethod: OAuth.RedirectMethod.Web, - providerName: "Notion", - providerIcon: "notion-logo.png", - providerId: "notion", - description: "Connect your Notion account", -}); - -const { notion_token } = getPreferenceValues(); - -let notion: Client | null = null; - -export const notionService = new OAuthService({ - client, - clientId: "c843219a-d93c-403c-8e4d-e8aa9a987494", - scope: "", - authorizeUrl: "https://notion.oauth.raycast.com/authorize", - tokenUrl: "https://notion.oauth.raycast.com/token", - personalAccessToken: notion_token, - extraParameters: { owner: "user" }, - onAuthorize({ token }) { - notion = new Client({ auth: token }); - }, -}); - -export function getNotionClient() { - if (!notion) { - throw new Error("No Notion client initialized"); +export type NotionAuthType = "oauth" | "internal"; +export type NotionAccountId = "account-1" | "account-2"; +export type NotionAccount = { id: NotionAccountId; label: string }; + +const ACTIVE_ACCOUNT_KEY = "NOTION_ACTIVE_ACCOUNT"; +const NOTION_CLIENT_ID = "c843219a-d93c-403c-8e4d-e8aa9a987494"; +const NOTION_AUTHORIZE_URL = "https://notion.oauth.raycast.com/authorize"; +const NOTION_TOKEN_URL = "https://notion.oauth.raycast.com/token"; + +const notionClients = new Map(); + +function getAuthPreferences() { + const preferences = getPreferenceValues(); + return { + authType: (preferences.notion_auth_type || "oauth") as NotionAuthType, + internalToken: preferences.notion_token?.trim(), + account1Label: preferences.notion_account_1_label?.trim(), + account2Label: preferences.notion_account_2_label?.trim(), + }; +} + +function buildAccountLabel(label: string | undefined, fallback: string) { + return label && label.length > 0 ? label : fallback; +} + +export function getNotionAccounts(): NotionAccount[] { + const { authType, account1Label, account2Label } = getAuthPreferences(); + const accounts: NotionAccount[] = [{ id: "account-1", label: buildAccountLabel(account1Label, "Account 1") }]; + + const hasSecondAccount = + authType === "oauth" && !!account1Label && account1Label.length > 0 && !!account2Label && account2Label.length > 0; + + if (hasSecondAccount) { + accounts.push({ id: "account-2", label: buildAccountLabel(account2Label, "Account 2") }); + } + + return accounts; +} + +export function hasMultipleAccounts() { + return getNotionAccounts().length > 1; +} + +export function getNotionAccount(accountId?: NotionAccountId) { + if (!accountId) return undefined; + const account = getNotionAccounts().find((item) => item.id === accountId); + if (account) return account; + return { id: accountId, label: accountId === "account-1" ? "Account 1" : "Account 2" }; +} + +export function getNotionAccountLabel(accountId?: NotionAccountId) { + if (!accountId) return undefined; + return getNotionAccount(accountId)?.label; +} + +export function resolveAccountIdFromLabel(label?: string): NotionAccountId | undefined { + if (!label) return undefined; + const normalized = label.trim().toLowerCase(); + const accounts = getNotionAccounts(); + const matched = accounts.find((account) => account.label.trim().toLowerCase() === normalized); + if (matched) return matched.id; + if (normalized === "account 1" || normalized === "account1" || normalized === "1") return "account-1"; + if (normalized === "account 2" || normalized === "account2" || normalized === "2") return "account-2"; + return undefined; +} + +export function resolveAccountIdForTool(accountLabel?: string): NotionAccountId { + const accounts = getNotionAccounts(); + const accountId = resolveAccountIdFromLabel(accountLabel); + + if (accountLabel && !accountId) { + throw new Error(`Unknown Notion account label: ${accountLabel}`); + } + + if (accounts.length > 1 && !accountId) { + throw new Error("Multiple Notion accounts are configured. Please specify which account to use."); + } + + return accountId ?? accounts[0]?.id ?? "account-1"; +} + +export function getDefaultAccountId(): NotionAccountId { + const accounts = getNotionAccounts(); + return accounts[0]?.id ?? "account-1"; +} + +export async function getActiveAccountId(): Promise { + const stored = await LocalStorage.getItem(ACTIVE_ACCOUNT_KEY); + const accounts = getNotionAccounts(); + const fallback = accounts[0]?.id ?? "account-1"; + if (stored && accounts.some((account) => account.id === stored)) return stored as NotionAccountId; + return fallback; +} + +export async function setActiveAccountId(accountId: NotionAccountId) { + await LocalStorage.setItem(ACTIVE_ACCOUNT_KEY, accountId); +} + +function getCachedClient(cacheKey: string, token: string) { + const cached = notionClients.get(cacheKey); + if (cached && cached.token === token) return cached.client; + const client = new Client({ auth: token }); + notionClients.set(cacheKey, { token, client }); + return client; +} + +function createOAuthClient(account: NotionAccount) { + return new OAuth.PKCEClient({ + redirectMethod: OAuth.RedirectMethod.Web, + providerName: `Notion (${account.label})`, + providerIcon: "notion-logo.png", + providerId: `notion-${account.id}`, + description: "Connect your Notion account", + }); +} + +function createOAuthService(account: NotionAccount) { + const client = createOAuthClient(account); + return new OAuthService({ + client, + clientId: NOTION_CLIENT_ID, + scope: "", + authorizeUrl: NOTION_AUTHORIZE_URL, + tokenUrl: NOTION_TOKEN_URL, + extraParameters: { owner: "user" }, + }); +} + +export async function getNotionClient(accountId?: NotionAccountId) { + const { authType, internalToken } = getAuthPreferences(); + + if (authType === "internal") { + if (!internalToken) { + await showToast({ + style: Toast.Style.Failure, + title: "Internal Integration Secret required", + message: "Set Authentication Type to OAuth or add your Internal Integration Secret in settings.", + }); + throw new Error("Internal Integration Secret is not configured"); + } + return getCachedClient("internal", internalToken); } - return notion; + const resolvedAccountId = accountId ?? (await getActiveAccountId()); + const account = getNotionAccount(resolvedAccountId) ?? { id: "account-1", label: "Account 1" }; + const token = await createOAuthService(account).authorize(); + return getCachedClient(account.id, token); } diff --git a/extensions/notion/src/utils/notion/page/index.ts b/extensions/notion/src/utils/notion/page/index.ts index 259a4f18d829..e2cf86617ee9 100644 --- a/extensions/notion/src/utils/notion/page/index.ts +++ b/extensions/notion/src/utils/notion/page/index.ts @@ -6,28 +6,28 @@ import { NotionToMarkdown } from "notion-to-md"; import { isMarkdownPageContent, PageContent } from ".."; import { getDateMention } from "../block"; import { handleError, pageMapper } from "../global"; -import { getNotionClient } from "../oauth"; +import { getNotionClient, NotionAccountId } from "../oauth"; import { PageProperty } from "./property"; export * from "./property"; -export async function fetchPage(pageId: string, silent: boolean = true) { +export async function fetchPage(pageId: string, silent: boolean = true, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const page = await notion.pages.retrieve({ page_id: pageId, }); - return pageMapper(page); + return pageMapper(page, accountId); } catch (err) { if (!silent) return handleError(err, "Failed to fetch page", undefined); } } -export async function deletePage(pageId: string) { +export async function deletePage(pageId: string, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); await showToast({ style: Toast.Style.Animated, @@ -48,22 +48,26 @@ export async function deletePage(pageId: string) { } } -export async function patchPage(pageId: string, properties: UpdatePageParameters["properties"]) { +export async function patchPage( + pageId: string, + properties: UpdatePageParameters["properties"], + accountId?: NotionAccountId, +) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const page = await notion.pages.update({ page_id: pageId, properties, }); - return pageMapper(page); + return pageMapper(page, accountId); } catch (err) { return handleError(err, "Failed to update page", undefined); } } -export async function search(query?: string, nextCursor?: string, pageSize: number = 25) { - const notion = getNotionClient(); +export async function search(query?: string, nextCursor?: string, pageSize: number = 25, accountId?: NotionAccountId) { + const notion = await getNotionClient(accountId); const database = await notion.search({ sort: { direction: "descending", @@ -74,12 +78,16 @@ export async function search(query?: string, nextCursor?: string, pageSize: numb ...(nextCursor && { start_cursor: nextCursor }), }); - return { pages: database.results.map(pageMapper), hasMore: database.has_more, nextCursor: database.next_cursor }; + return { + pages: database.results.map((page) => pageMapper(page, accountId)), + hasMore: database.has_more, + nextCursor: database.next_cursor, + }; } -export async function fetchPageContent(pageId: string) { +export async function fetchPageContent(pageId: string, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const { results } = await notion.blocks.children.list({ block_id: pageId, }); @@ -95,9 +103,9 @@ export async function fetchPageContent(pageId: string) { } } -export async function fetchPageFirstBlockId(pageId: string) { +export async function fetchPageFirstBlockId(pageId: string, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const { results } = await notion.blocks.children.list({ block_id: pageId, }); @@ -112,6 +120,7 @@ type AppendBlockToPageParams = { children: BlockObjectRequest[]; prepend?: boolean; addDateDivider?: boolean; + accountId?: NotionAccountId; }; export async function appendBlockToPage({ @@ -119,12 +128,13 @@ export async function appendBlockToPage({ children, prepend = false, addDateDivider = false, + accountId, }: AppendBlockToPageParams) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const childrenToInsert = addDateDivider ? [{ divider: {} }, getDateMention(), ...children] : children; - const insertAfter = prepend ? await fetchPageFirstBlockId(pageId) : undefined; + const insertAfter = prepend ? await fetchPageFirstBlockId(pageId, accountId) : undefined; const { results } = await notion.blocks.children.append({ block_id: pageId, @@ -138,9 +148,9 @@ export async function appendBlockToPage({ } } -export async function appendToPage(pageId: string, params: { content: PageContent }) { +export async function appendToPage(pageId: string, params: { content: PageContent }, accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const { content } = params; const { results } = await notion.blocks.children.append({ @@ -179,6 +189,7 @@ export function getPageName(page: Page): string { export interface Page { object: "page" | "database"; id: string; + accountId?: NotionAccountId; parent_page_id?: string; parent_database_id?: string; created_by?: string; diff --git a/extensions/notion/src/utils/notion/user.ts b/extensions/notion/src/utils/notion/user.ts index 3cf4e85bf280..a1647bf13158 100644 --- a/extensions/notion/src/utils/notion/user.ts +++ b/extensions/notion/src/utils/notion/user.ts @@ -1,11 +1,11 @@ import { iteratePaginatedAPI } from "@notionhq/client"; import { handleError, isNotNullOrUndefined } from "./global"; -import { getNotionClient } from "./oauth"; +import { getNotionClient, NotionAccountId } from "./oauth"; -export async function fetchUsers() { +export async function fetchUsers(accountId?: NotionAccountId) { try { - const notion = getNotionClient(); + const notion = await getNotionClient(accountId); const users = []; for await (const user of iteratePaginatedAPI(notion.users.list, {})) { users.push(user);