@jitl/notion-api
Version:
The missing companion library for the official Notion public API.
758 lines • 25.7 kB
JavaScript
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
;