UNPKG

@jitl/notion-api

Version:

The missing companion library for the official Notion public API.

758 lines 25.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.getAllProperties = exports.databaseSchemaDiffToString = exports.diffDatabaseSchemas = exports.inferDatabaseSchema = exports.getPropertySchemaData = exports.isFullDatabase = exports.getPropertyValue = exports.getProperty = exports.getFormulaPropertyValueData = exports.getPropertyData = exports.Sort = exports.Filter = exports.visitTextTokens = exports.notionDateStartAsDate = exports.richTextAsPlainText = exports.visitChildBlocks = exports.getBlockWithChildren = exports.getChildBlocksWithChildrenRecursively = exports.getChildBlocks = exports.isBlockWithChildren = exports.getBlockData = exports.isFullBlockFilter = exports.isFullBlock = exports.isPageWithChildren = exports.isFullPage = exports.asyncIterableToArray = exports.iteratePaginatedAPI = exports.NotionClient = exports.NotionClientDebugLogger = exports.DEBUG = void 0; const tslib_1 = require("tslib"); /** * This file contains base types and utilities for working with Notion's public * API. * @category API * @module */ const util_1 = require("@jitl/util"); const client_1 = require("@notionhq/client"); Object.defineProperty(exports, "NotionClient", { enumerable: true, get: function () { return client_1.Client; } }); const fast_safe_stringify_1 = tslib_1.__importDefault(require("fast-safe-stringify")); const debug_1 = require("debug"); //////////////////////////////////////////////////////////////////////////////// // Debugging. //////////////////////////////////////////////////////////////////////////////// /** @private */ exports.DEBUG = (0, debug_1.debug)('@jitl/notion-api'); // API debugging w/ DEBUG=* const DEBUG_API = exports.DEBUG.extend('api'); const DEBUG_API_LEVEL = { debug: DEBUG_API.extend('debug'), info: DEBUG_API.extend('info'), warn: DEBUG_API.extend('warn'), error: DEBUG_API.extend('error'), }; const logger = (level, message, extraInfo) => { DEBUG_API_LEVEL[level]('%s %o', message, extraInfo); }; /** * @category API * * A logger for the @notionhq/client Client that logs to the @jitl/notion-api * namespace. * * * @example * ```typescript * const client = new NotionClient({ * logger: NotionClientDebugLogger, * // ... * }) * ``` */ exports.NotionClientDebugLogger = Object.assign(logger, DEBUG_API_LEVEL); const DEBUG_ITERATE = exports.DEBUG.extend('iterate'); /** * Iterate over all results in a paginated list API. * * ```typescript * for await (const block of iteratePaginatedAPI(notion.blocks.children.list, { * block_id: parentBlockId, * })) { * // Do something with block. * } * ``` * * @param listFn API to call * @param firstPageArgs These arguments are used for each page, with an updated `start_cursor`. * @category API */ async function* iteratePaginatedAPI(listFn, firstPageArgs) { let next_cursor = firstPageArgs.start_cursor; let has_more = true; let results = []; let total = 0; let page = 0; while (has_more) { ({ results, next_cursor, has_more } = await listFn({ ...firstPageArgs, start_cursor: next_cursor, })); page++; total += results.length; DEBUG_ITERATE('%s: fetched page %s, %s (%s, %s total)', listFn.name, page, next_cursor ? 'done' : 'has more', results.length, total); yield* results; } } exports.iteratePaginatedAPI = iteratePaginatedAPI; /** * Gather all an async iterable's items into an array. * ```typescript * const iterator = iteratePaginatedAPI(notion.blocks.children.list, { block_id: parentBlockId }); * const blocks = await asyncIterableToArray(iterator); * const paragraphs = blocks.filter(block => isFullBlock(block, 'paragraph')) * ``` * @category API */ async function asyncIterableToArray(iterable) { const array = []; for await (const item of iterable) { array.push(item); } return array; } exports.asyncIterableToArray = asyncIterableToArray; /** * The Notion API may return a "partial" page object if your API token can't * access the page. * * This function confirms that all page data is available. * @category Page */ function isFullPage(page) { return 'parent' in page; } exports.isFullPage = isFullPage; /** * @category Page */ function isPageWithChildren(page) { return isFullPage(page) && 'children' in page; } exports.isPageWithChildren = isPageWithChildren; function isFullBlock(block, type) { return 'type' in block && type ? block.type === type : true; } exports.isFullBlock = isFullBlock; /** * Returns a filter type guard for blocks of the given `type`. * See [[isFullBlock]] for more information. * * ```typescript * const paragraphs: Array<Block<"paragraph">> = blocks.filter(isFullBlockFilter("paragraph")); * ``` * * @category Block */ function isFullBlockFilter(type) { return ((block) => isFullBlock(block, type)); } exports.isFullBlockFilter = isFullBlockFilter; /** * Generic way to get a block's data. * @category Block */ function getBlockData(block) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return block[block.type]; } exports.getBlockData = getBlockData; /** * @category Block */ function isBlockWithChildren(block) { return isFullBlock(block) && 'children' in block; } exports.isBlockWithChildren = isBlockWithChildren; //////////////////////////////////////////////////////////////////////////////// // Block children. //////////////////////////////////////////////////////////////////////////////// /** * Fetch all supported children of a block. * @category Block */ async function getChildBlocks(notion, parentBlockId) { const blocks = []; for await (const block of iteratePaginatedAPI(notion.blocks.children.list, { block_id: parentBlockId, })) { if (isFullBlock(block)) { blocks.push(block); } } return blocks; } exports.getChildBlocks = getChildBlocks; const DEBUG_CHILDREN = exports.DEBUG.extend('children'); /** * Recursively fetch all children of `parentBlockId` as `BlockWithChildren`. * This function can be used to fetch an entire page's contents in one call. * @category Block */ async function getChildBlocksWithChildrenRecursively(notion, parentId) { const blocks = (await getChildBlocks(notion, parentId)); DEBUG_CHILDREN('parent %s: fetched %s children', parentId, blocks.length); if (blocks.length === 0) { return []; } const result = await Promise.all(blocks.map(async (block) => { if (block.has_children) { block.children = await getChildBlocksWithChildrenRecursively(notion, block.id); } else { block.children = []; } return block; })); DEBUG_CHILDREN('parent %s: finished descendants', parentId); return result; } exports.getChildBlocksWithChildrenRecursively = getChildBlocksWithChildrenRecursively; // TODO: remove? /** * @category Block */ async function getBlockWithChildren(notion, blockId) { const block = (await notion.blocks.retrieve({ block_id: blockId, })); if (!isFullBlock(block)) { return undefined; } if (block.has_children) { block.children = await getChildBlocksWithChildrenRecursively(notion, block.id); } else { block.children = []; } return block; } exports.getBlockWithChildren = getBlockWithChildren; /** * Recurse over the blocks and their children, calling `fn` on each block. * @category Block */ function visitChildBlocks(blocks, fn) { for (const block of blocks) { visitChildBlocks(block.children, fn); fn(block); } } exports.visitChildBlocks = visitChildBlocks; //////////////////////////////////////////////////////////////////////////////// // Rich text. //////////////////////////////////////////////////////////////////////////////// const RICH_TEXT_BLOCK_PROPERTY = 'rich_text'; /** * @returns Plaintext string of rich text. * @category Rich Text */ function richTextAsPlainText(richText) { if (!richText) { return ''; } if (typeof richText === 'string') { return richText; } return richText.map((token) => token.plain_text).join(''); } exports.richTextAsPlainText = richTextAsPlainText; function notionDateStartAsDate(date) { if (!date) { return undefined; } if (date instanceof Date) { return date; } const start = date.start; return new Date(start); } exports.notionDateStartAsDate = notionDateStartAsDate; /** * Visit all text tokens in a block or page. Relations are treated as mention * tokens. Does not consider children. * @category Rich Text */ function visitTextTokens(object, fn) { if (object.object === 'page') { for (const [, prop] of (0, util_1.objectEntries)(object.properties)) { switch (prop.type) { case 'title': case 'rich_text': getPropertyData(prop).forEach((token) => fn(token)); break; case 'relation': getPropertyData(prop).forEach((relation) => { const token = { type: 'mention', mention: { type: 'page', page: relation, }, annotations: { bold: false, code: false, color: 'default', italic: false, strikethrough: false, underline: false, }, href: null, plain_text: relation.id, }; fn(token); }); break; } } } if (object.object === 'block') { const blockData = getBlockData(object); if (RICH_TEXT_BLOCK_PROPERTY in blockData) { blockData[RICH_TEXT_BLOCK_PROPERTY].forEach(fn); } if ('caption' in blockData) { blockData.caption.forEach(fn); } } } exports.visitTextTokens = visitTextTokens; /** * Filter builder functions. * @category Query */ exports.Filter = { /** * Syntax sugar for building a [[PropertyFilter]]. */ property: (filter) => { return filter; }, /** * Build a [[CompoundFilter]] from multiple arguments, or otherwise * return the only filter. * * Note that the Notion API limits filter depth, but this function does not. */ compound: (type, ...filters) => { const defined = filters.filter((x) => Boolean(x)); if (defined.length === 0) { return; } if (defined.length === 1) { return defined[0]; } switch (type) { case 'and': { // Optimization: lift up and combine `and` filter terms. const lifted = defined.filter(exports.Filter.isAnd).flatMap(({ and }) => and); const notLifted = defined.filter((subfilter) => !exports.Filter.isAnd(subfilter)); return { and: [...lifted, ...notLifted] }; } case 'or': { // Optimization: lift up and combine `or` filter terms. const lifted = defined.filter(exports.Filter.isOr).flatMap(({ or }) => or); const notLifted = defined.filter((subfilter) => !exports.Filter.isOr(subfilter)); return { or: [...lifted, ...notLifted] }; } default: (0, util_1.unreachable)(type); } }, /** * @returns True if `filter` is a [[CompoundFilter]]. */ isCompound: (filter) => exports.Filter.isOr(filter) || exports.Filter.isAnd(filter), /** * Build an `and` [[CompoundFilter]] from multiple arguments, or otherwise * return the only filter. * * Note that the Notion API limits filter depth, but this function does not. */ and: (...filters) => exports.Filter.compound('and', ...filters), /** * @returns True if `filter` is an `and` [[CompoundFilter]]. */ isAnd: (filter) => 'and' in filter, /** * Build an `or` [[CompoundFilter]] from multiple arguments, or otherwise * return the only filter. * * Note that the Notion API limits filter depth, but this function does not. */ or: (...filters) => exports.Filter.compound('or', ...filters), /** * @returns True if `filter` is an `or` [[CompoundFilter]]. */ isOr: (filter) => 'or' in filter, }; /** * Sort builder functions. * @category Query */ exports.Sort = { property: { ascending: (property) => ({ property, direction: 'ascending', }), descending: (property) => ({ property, direction: 'descending', }), }, created_time: { ascending: { timestamp: 'created_time', direction: 'ascending', }, descending: { timestamp: 'created_time', direction: 'descending', }, }, last_edited_time: { ascending: { timestamp: 'last_edited_time', direction: 'ascending', }, descending: { timestamp: 'last_edited_time', direction: 'descending', }, }, }; /** * Generic way to get a property's data. * Suggested usage is with a switch statement on property.type to narrow the * result. * * ``` * switch (prop.type) { * case 'title': * case 'rich_text': * getPropertyData(prop).forEach((token) => fn(token)); * break; * // ... * } * ``` * @category Property */ function getPropertyData(property) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return property[property.type]; } exports.getPropertyData = getPropertyData; /** * @category Property */ function getFormulaPropertyValueData(propertyValue) { var _a; // eslint-disable-next-line @typescript-eslint/no-explicit-any return (_a = propertyValue[propertyValue.type]) !== null && _a !== void 0 ? _a : null; } exports.getFormulaPropertyValueData = getFormulaPropertyValueData; /** * Get the property with `name` and/or `id` from `page`. * @param page * @param property Which property? * @returns The property with that name or id, or undefined if not found. * @category Property */ function getProperty(page, { name, id }) { const property = page.properties[name]; if (property && id ? id === property.id : true) { return property; } if (id) { return Object.values(page.properties).find((property) => property.id === id); } return undefined; } exports.getProperty = getProperty; function getPropertyValue(page, propertyPointer, transform) { const property = getProperty(page, propertyPointer); if (property && property.type === propertyPointer.type) { const propertyValue = property[propertyPointer.type]; if (propertyValue !== undefined) { return transform ? transform(propertyValue) : propertyValue; } } } exports.getPropertyValue = getPropertyValue; /** * The Notion API may return a "partial" database object if your API token doesn't * have permission for the full database. * * This function confirms that all database data is available. * @category Database */ function isFullDatabase(database) { return 'title' in database; } exports.isFullDatabase = isFullDatabase; /** * Get the type-specific schema data of `propertySchema`. * @param propertySchema * @returns * @category Database */ function getPropertySchemaData(propertySchema) { return propertySchema[propertySchema.type]; } exports.getPropertySchemaData = getPropertySchemaData; /** * This function helps you infer a concrete subtype of [[PartialDatabaseSchema]] * for use with other APIs in this package. It will fill in missing `name` * fields of each [[PartialPropertySchema]] with the object's key. * * Use the fields of the returned schema to access a page's properties via * [[getPropertyValue]] or [[getProperty]]. * * You can check or update your inferred schema against data fetched from the * API with [[compareDatabaseSchema]]. * * ```typescript * const mySchema = inferDatabaseSchema({ * Title: { type: 'title' }, * SubTitle: { type: 'rich_text', name: 'Subtitle' }, * PublishedDate: { type: 'date', name: 'Published Date' }, * IsPublished: { * type: 'checkbox', * name: 'Show In Production', * id: 'asdf123', * }, * }); * * // inferDatabaseSchema infers a concrete type with the same shape as the input, * // so you can reference properties easily. It also adds `name` to each [[PropertySchema]] * // based on the key name. * console.log(mySchema.Title.name); // "Title" * * // You can use the properties in the inferred schema to access the corresponding * // property value on a Page. * for await (const page of iteratePaginatedAPI(notion.databases.query, { * database_id, * })) { * if (isFullPage(page)) { * const titleRichText = getPropertyValue(page, mySchema.Title); * console.log('Title: ', richTextAsPlainText(titleRichText)); * const isPublished = getPropertyValue(page, mySchema.IsPublished); * console.log('Is published: ', isPublished); * } * } * ``` * * @param schema A partial database schema object literal. * @returns The inferred PartialDatabaseSchema subtype. * @category Database */ function inferDatabaseSchema(schema) { const result = {}; for (const [key, propertySchema] of (0, util_1.objectEntries)(schema)) { // Already has name if ('name' in propertySchema && propertySchema['name'] !== undefined) { result[key] = propertySchema; continue; } // Needs a name result[key] = { ...propertySchema, name: key, }; } return result; } exports.inferDatabaseSchema = inferDatabaseSchema; function getPropertySchema(schema, pointer) { for (const [key, propertySchema] of (0, util_1.objectEntries)(schema)) { if (pointer.id && propertySchema.id === pointer.id) { return { key, property: propertySchema }; } if (propertySchema.name === pointer.name) { return { key, property: propertySchema }; } } } /** * Diff a `before` and `after` database schemas. * * You can use this to validate an inferred schema literal against the actual * schema fetched from the Notion API. * * ```typescript * const mySchema = inferDatabaseSchema({ * Title: { type: 'title' }, * SubTitle: { type: 'rich_text', name: 'Subtitle' }, * PublishedDate: { type: 'date', name: 'Published Date' }, * IsPublished: { * type: 'checkbox', * name: 'Show In Production', * id: 'asdf123', * }, * }); * * // Print schema differences between our literal and the API. * const database = await notion.databases.retrieve({ database_id }); * const diffs = diffDatabaseSchemas({ before: mySchema, after: database.properties }); * for (const change of diffs) { * console.log( * databaseSchemaDiffToString(change, { beforeName: "mySchema", afterName: "API database" }) * ); * } * ``` * * @returns An array of diffs between the `before` and `after` schemas. * @warning This is O(N * M) over length of the schemas currently, but may be optimized in the future. * @category Database */ function diffDatabaseSchemas(args) { const { before, after } = args; const result = []; const diff = (diff) => result.push(diff); for (const [beforeKey, beforeProp] of (0, util_1.objectEntries)(before)) { const resolved = getPropertySchema(after, beforeProp); if (!resolved) { diff({ type: 'removed', property: beforeProp, before: beforeKey, }); continue; } const { key: afterKey, property: afterProp } = resolved; if (beforeKey !== afterKey) { diff({ type: 'key', property: afterProp, before: beforeKey, after: afterKey, }); } if (!beforeProp.id && afterProp.id) { diff({ type: 'property.id.added', property: beforeProp, id: afterProp.id, }); } if (!afterProp.id && beforeProp.id) { diff({ type: 'property.id.removed', property: afterProp, id: beforeProp.id, }); } if (beforeProp.name !== afterProp.name) { diff({ type: 'property.name', property: afterProp, before: beforeProp.name, after: afterProp.name, }); } let typeChanged = false; if (beforeProp.type !== afterProp.type) { typeChanged = true; diff({ type: 'property.type', before: beforeProp.type, after: afterProp.type, property: afterProp, }); } if (!typeChanged && beforeProp.type in beforeProp && afterProp.type in afterProp) { const beforePropSchema = beforeProp; const afterPropSchema = afterProp; const beforeData = getPropertySchemaData(beforePropSchema); const afterData = getPropertySchemaData(afterPropSchema); const beforeSerialized = fast_safe_stringify_1.default.stable(beforeData); const afterSerialized = fast_safe_stringify_1.default.stable(afterData); if (beforeSerialized !== afterSerialized) { diff({ type: 'property.schema', before: beforePropSchema, after: afterPropSchema, }); } } } for (const [afterKey, afterProp] of (0, util_1.objectEntries)(after)) { const resolved = getPropertySchema(before, afterProp); if (!resolved) { diff({ type: 'added', property: afterProp, after: afterKey, }); } } return result; } exports.diffDatabaseSchemas = diffDatabaseSchemas; /** * See [[diffDatabaseSchemas]]. * @returns A string describing a diff between two database schemas. * @category Database */ function databaseSchemaDiffToString(diff, options = {}) { const { beforeName = 'before', afterName = 'after' } = options; function fmtKey(name) { return `["${String(name)}"]`; } function fmtName(name) { return `name: "${name}"`; } function fmtId(id) { return id && `id: ${id}`; } function fmtType(type) { return `type: ${type}`; } function fmtData(data) { if (!data || Object.keys(data).length === 0) { return; } return `${JSON.stringify(data)}`; } function parts(...args) { return args.filter(Boolean).join(', '); } function summarize(property) { const name = fmtName(property.name); const id = property.id && fmtId(property.id); const type = 'type' in property && fmtType(property.type); const data = 'type' in property && property.type in property && getPropertySchemaData(property); return parts(name, id, type, data && fmtData(data)); } switch (diff.type) { case 'added': { return `${diff.type}: +${afterName}${fmtKey(diff.after)} ${summarize(diff.property)}`; } case 'removed': { return `${diff.type}: -${beforeName}${fmtKey(diff.before)} ${summarize(diff.property)}`; } case 'key': { return `${diff.type} renamed: ${beforeName}${fmtKey(diff.before)} -> ${afterName}${fmtKey(diff.after)}`; } case 'property.name': { return `property renamed: { ${fmtId(diff.property.id)} ${fmtName(diff.before)} -> ${fmtName(diff.after)} }`; } case 'property.id.added': { return `property id added: { ${fmtName(diff.property.name)} +${fmtId(diff.id)} }`; } case 'property.id.removed': { return `property id removed: { ${fmtName(diff.property.name)} -${fmtId(diff.id)} }`; } case 'property.type': { return `property type changed: { ${parts(fmtId(diff.property.id), fmtName(diff.property.name))} ${fmtType(diff.before)} -> ${fmtType(diff.after)} }`; } case 'property.schema': { return `property schema changed: { ${parts(fmtId(diff.after.id), fmtName(diff.after.name), fmtType(diff.after.type))} ${fmtData(getPropertySchemaData(diff.before))} -> ${fmtData(getPropertySchemaData(diff.after))} }`; } default: (0, util_1.unreachable)(diff); } } exports.databaseSchemaDiffToString = databaseSchemaDiffToString; /** * Get all properties in a schema from the database. * * @category Property * @category Database */ function getAllProperties(page, schema) { const result = {}; for (const [key, property] of (0, util_1.objectEntries)(schema)) { result[key] = getPropertyValue(page, property); } return result; } exports.getAllProperties = getAllProperties; //# sourceMappingURL=notion-api.js.map