diff --git a/src/api/notion.ts b/src/api/notion.ts index 96ee6a1..b88e5aa 100644 --- a/src/api/notion.ts +++ b/src/api/notion.ts @@ -4,6 +4,8 @@ import { NotionUserType, LoadPageChunkData, CollectionData, + NotionSearchParamsType, + NotionSearchResultsType, } from "./types"; const NOTION_API = "https://www.notion.so/api/v3"; @@ -120,3 +122,34 @@ export const fetchBlocks = async ( notionToken, }); }; + +export const fetchNotionSearch = async ( + params: NotionSearchParamsType, + notionToken?: string +) => { + // TODO: support other types of searches + return fetchNotionData<{ results: NotionSearchResultsType }>({ + resource: "search", + body: { + type: "BlocksInAncestor", + source: "quick_find_public", + ancestorId: params.ancestorId, + filters: { + isDeletedOnly: false, + excludeTemplates: true, + isNavigableOnly: true, + requireEditPermissions: false, + ancestors: [], + createdBy: [], + editedBy: [], + lastEditedTime: {}, + createdTime: {}, + ...params.filters, + }, + sort: "Relevance", + limit: params.limit || 20, + query: params.query, + }, + notionToken, + }); +}; diff --git a/src/api/types.ts b/src/api/types.ts index f3306c1..1dadea5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,3 +1,5 @@ +import { Params } from "tiny-request-router"; + type BoldFormatType = ["b"]; type ItalicFormatType = ["i"]; type StrikeFormatType = ["s"]; @@ -146,3 +148,40 @@ export interface CollectionData { blockIds: string[]; }; } + +export interface NotionSearchParamsType { + ancestorId: string; + query: string; + filters?: { + isDeletedOnly: boolean; + excludeTemplates: boolean; + isNavigableOnly: boolean; + requireEditPermissions: boolean; + }; + limit?: number; +} + +export interface NotionSearchResultType { + id: string; + isNavigable: boolean; + score: number; + highlight: { + pathText: string; + text: string; + }; +} + +export interface NotionSearchResultsType { + recordMap: { + block: { [key: string]: RowType }; + }; + results: NotionSearchResultType[]; + total: number; +} + +export interface HandlerRequest = { + params: Params; + searchParams: URLSearchParams; + request: Request; + notionToken?: string; +} diff --git a/src/api/utils.ts b/src/api/utils.ts index 2757dd9..d970619 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -7,8 +7,10 @@ export const idToUuid = (path: string) => )}-${path.substr(16, 4)}-${path.substr(20)}`; export const parsePageId = (id: string) => { - const rawId = id.replace(/\-/g, "").slice(-32); - return idToUuid(rawId); + if (id) { + const rawId = id.replace(/\-/g, "").slice(-32); + return idToUuid(rawId); + } }; export const getNotionValue = ( @@ -20,7 +22,7 @@ export const getNotionValue = ( return getTextContent(val); case "person": return ( - val.filter(v => v.length > 1).map(v => v[1]![0][1] as string) || [] + val.filter((v) => v.length > 1).map((v) => v[1]![0][1] as string) || [] ); case "checkbox": return val[0][0] === "Yes"; diff --git a/src/index.ts b/src/index.ts index 756aec7..a7e3555 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,19 +4,27 @@ import { Router, Method, Params } from "tiny-request-router"; import { pageRoute } from "./routes/page"; import { tableRoute } from "./routes/table"; import { userRoute } from "./routes/user"; +import { searchRoute } from "./routes/search"; import { createResponse } from "./response"; +import * as types from "./api/types"; export type Handler = ( - params: Params, - notionToken?: string + req: types.HandlerRequest ) => Promise | Response; +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", +}; + const router = new Router(); -router.options("*", () => new Response("", { headers: {} })); +router.options("*", () => new Response(null, { headers: corsHeaders })); router.get("/v1/page/:pageId", pageRoute); router.get("/v1/table/:pageId", tableRoute); router.get("/v1/user/:userId", userRoute); +router.get("/v1/search", searchRoute); router.get("*", async () => createResponse( @@ -35,7 +43,7 @@ const NOTION_API_TOKEN = const handleRequest = async (fetchEvent: FetchEvent): Promise => { const request = fetchEvent.request; - const { pathname } = new URL(request.url); + const { pathname, searchParams } = new URL(request.url); const notionToken = NOTION_API_TOKEN || (request.headers.get("Authorization") || "").split("Bearer ")[1] || @@ -57,7 +65,12 @@ const handleRequest = async (fetchEvent: FetchEvent): Promise => { } catch (err) {} const getResponseAndPersist = async () => { - const res = await match.handler(match.params, notionToken); + const res = await match.handler({ + request, + searchParams, + params: match.params, + notionToken, + }); await cache.put(cacheKey, res.clone()); return res; diff --git a/src/routes/page.ts b/src/routes/page.ts index a1df25f..7c1d6cc 100644 --- a/src/routes/page.ts +++ b/src/routes/page.ts @@ -3,30 +3,39 @@ import { fetchPageById, fetchBlocks } from "../api/notion"; import { parsePageId } from "../api/utils"; import { createResponse } from "../response"; import { getTableData } from "./table"; -import { CollectionType, BlockType } from "../api/types"; +import { CollectionType, BlockType, HandlerRequest } from "../api/types"; -export async function pageRoute(params: Params, notionToken?: string) { - const pageId = parsePageId(params.pageId); - const page = await fetchPageById(pageId, notionToken); +export async function pageRoute(req: HandlerRequest) { + const pageId = parsePageId(req.params.pageId); + const page = await fetchPageById(pageId, req.notionToken); const baseBlocks = page.recordMap.block; - const baseBlockKeys = Object.keys(baseBlocks); - const pendingBlocks = baseBlockKeys.flatMap((blockId) => { - const block = baseBlocks[blockId]; - const content = block.value.content; - - return content ? content.filter((id: string) => !baseBlocks[id]) : []; - }); - - const additionalBlocks = await fetchBlocks(pendingBlocks).then( - (res) => res.recordMap.block - ); - - const allBlocks: { [id: string]: BlockType & { data?: any } } = { + let allBlocks: { [id: string]: BlockType & { data?: any } } = { ...baseBlocks, - ...additionalBlocks }; + let allBlockKeys; + + while (true) { + allBlockKeys = Object.keys(allBlocks); + + const pendingBlocks = allBlockKeys.flatMap((blockId) => { + const block = allBlocks[blockId]; + const content = block.value.content; + + return content ? content.filter((id: string) => !allBlocks[id]) : []; + }); + + if (!pendingBlocks.length) { + break; + } + + const newBlocks = await fetchBlocks(pendingBlocks).then( + (res) => res.recordMap.block + ); + + allBlocks = { ...allBlocks, ...newBlocks }; + } const collection = page.recordMap.collection ? page.recordMap.collection[Object.keys(page.recordMap.collection)[0]] @@ -41,8 +50,8 @@ export async function pageRoute(params: Params, notionToken?: string) { : null; if (collection && collectionView) { - const pendingCollections = baseBlockKeys.flatMap((blockId) => { - const block = baseBlocks[blockId]; + const pendingCollections = allBlockKeys.flatMap((blockId) => { + const block = allBlocks[blockId]; return block.value.type === "collection_view" ? [block.value.id] : []; }); @@ -51,12 +60,12 @@ export async function pageRoute(params: Params, notionToken?: string) { const data = await getTableData( collection, collectionView.value.id, - notionToken + req.notionToken ); allBlocks[b] = { ...allBlocks[b], - data + data, }; } } diff --git a/src/routes/search.ts b/src/routes/search.ts new file mode 100644 index 0000000..1e9655a --- /dev/null +++ b/src/routes/search.ts @@ -0,0 +1,29 @@ +import { fetchNotionSearch } from "../api/notion"; +import { createResponse } from "../response"; +import { HandlerRequest } from "../api/types"; +import { parsePageId } from "../api/utils"; + +export async function searchRoute(req: HandlerRequest) { + const ancestorId = parsePageId(req.searchParams.get("ancestorId")); + const query = req.searchParams.get("query") || ""; + const limit = req.searchParams.get("limit") || 20; + + if (!ancestorId) { + return createResponse( + { error: 'missing required "ancestorId"' }, + { "Content-Type": "application/json" }, + 400 + ); + } + + const results = await fetchNotionSearch( + { + ancestorId, + query, + limit, + }, + req.notionToken + ); + + return createResponse(results); +} diff --git a/src/routes/table.ts b/src/routes/table.ts index bfa1682..59d7b02 100644 --- a/src/routes/table.ts +++ b/src/routes/table.ts @@ -1,7 +1,12 @@ import { Params } from "tiny-request-router"; import { fetchPageById, fetchTableData, fetchNotionUsers } from "../api/notion"; import { parsePageId, getNotionValue } from "../api/utils"; -import { RowContentType, CollectionType, RowType } from "../api/types"; +import { + RowContentType, + CollectionType, + RowType, + HandlerRequest, +} from "../api/types"; import { createResponse } from "../response"; export const getTableData = async ( @@ -51,9 +56,9 @@ export const getTableData = async ( return rows; }; -export async function tableRoute(params: Params, notionToken?: string) { - const pageId = parsePageId(params.pageId); - const page = await fetchPageById(pageId, notionToken); +export async function tableRoute(req: HandlerRequest) { + const pageId = parsePageId(req.params.pageId); + const page = await fetchPageById(pageId, req.notionToken); if (!page.recordMap.collection) return createResponse( @@ -75,7 +80,7 @@ export async function tableRoute(params: Params, notionToken?: string) { const rows = await getTableData( collection, collectionView.value.id, - notionToken + req.notionToken ); return createResponse(rows); diff --git a/src/routes/user.ts b/src/routes/user.ts index c4b8153..a847457 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,9 +1,10 @@ import { Params } from "tiny-request-router"; import { fetchNotionUsers } from "../api/notion"; +import { HandlerRequest } from "../api/types"; import { createResponse } from "../response"; -export async function userRoute(params: Params, notionToken?: string) { - const users = await fetchNotionUsers([params.userId], notionToken); +export async function userRoute(req: HandlerRequest) { + const users = await fetchNotionUsers([req.params.userId], req.notionToken); return createResponse(users[0]); }