@coastal-programs/notion-cli
Version:
Unofficial Notion CLI optimized for automation and AI agents. Non-interactive interface for Notion API v5.2.1 with intelligent caching, retry logic, structured error handling, and comprehensive testing.
886 lines (885 loc) • 29.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildBlockUpdateFromTextFlags = exports.getChildDatabasesWithIds = exports.enrichChildDatabaseBlock = exports.buildBlocksFromTextFlags = exports.getBlockPlainText = exports.getPageTitle = exports.getDataSourceTitle = exports.getDbTitle = exports.buildOneDepthJson = exports.buildPagePropUpdateData = exports.buildDatabaseQueryFilter = exports.getFilterFields = exports.showRawFlagHint = exports.outputPrettyTable = exports.outputMarkdownTable = exports.stripMetadata = exports.outputCompactJson = exports.outputRawJson = void 0;
const notion = require("./notion");
const client_1 = require("@notionhq/client");
const outputRawJson = async (res) => {
console.log(JSON.stringify(res, null, 2));
};
exports.outputRawJson = outputRawJson;
/**
* Output data as compact JSON (single-line, no formatting)
* Useful for piping to other tools or scripts
*/
const outputCompactJson = (res) => {
console.log(JSON.stringify(res));
};
exports.outputCompactJson = outputCompactJson;
/**
* Strip unnecessary metadata from Notion API responses to reduce size
* Removes created_by, last_edited_by, object fields, request_id, empty values, etc.
* Keeps timestamps (created_time, last_edited_time) and essential data
*
* @param data The data to strip metadata from (single object or array)
* @returns The stripped data
*/
const stripMetadata = (data) => {
if (Array.isArray(data)) {
return data.map(item => (0, exports.stripMetadata)(item));
}
if (data === null || typeof data !== 'object') {
return data;
}
const result = {};
for (const [key, value] of Object.entries(data)) {
// Skip fields that should be removed
if (key === 'created_by' ||
key === 'last_edited_by' ||
key === 'request_id' ||
key === 'object' ||
(key === 'has_more' && value === false)) {
continue;
}
// Skip empty arrays
if (Array.isArray(value) && value.length === 0) {
continue;
}
// Skip empty objects (but keep objects with properties)
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) {
continue;
}
// Recursively strip metadata from nested objects and arrays
if (value && typeof value === 'object') {
result[key] = (0, exports.stripMetadata)(value);
}
else {
result[key] = value;
}
}
return result;
};
exports.stripMetadata = stripMetadata;
/**
* Output data as a markdown table
* Converts column data into GitHub-flavored markdown table format
*/
const outputMarkdownTable = (data, columns) => {
if (!data || data.length === 0) {
console.log('No data to display');
return;
}
// Extract column headers
const headers = Object.keys(columns);
// Build header row
const headerRow = '| ' + headers.join(' | ') + ' |';
const separatorRow = '| ' + headers.map(() => '---').join(' | ') + ' |';
console.log(headerRow);
console.log(separatorRow);
// Build data rows
data.forEach((row) => {
const values = headers.map((header) => {
const column = columns[header];
let value;
// Handle column getter function
if (column.get && typeof column.get === 'function') {
value = column.get(row);
}
else if (column.header) {
// If column has a header property, use the key to get value
value = row[header];
}
else {
// Direct property access
value = row[header];
}
// Format value for markdown (escape pipes and handle nulls)
if (value === null || value === undefined) {
return '';
}
const stringValue = String(value).replace(/\|/g, '\\|').replace(/\n/g, ' ');
return stringValue;
});
console.log('| ' + values.join(' | ') + ' |');
});
};
exports.outputMarkdownTable = outputMarkdownTable;
/**
* Output data as a pretty table with borders
* Enhanced table format with better visual separation
*/
const outputPrettyTable = (data, columns) => {
if (!data || data.length === 0) {
console.log('No data to display');
return;
}
const headers = Object.keys(columns);
// Calculate column widths
const columnWidths = {};
headers.forEach((header) => {
columnWidths[header] = header.length;
});
// Calculate max width for each column based on data
data.forEach((row) => {
headers.forEach((header) => {
const column = columns[header];
let value;
if (column.get && typeof column.get === 'function') {
value = column.get(row);
}
else {
value = row[header];
}
const stringValue = String(value === null || value === undefined ? '' : value);
columnWidths[header] = Math.max(columnWidths[header], stringValue.length);
});
});
// Build separator line
const topBorder = '┌' + headers.map(h => '─'.repeat(columnWidths[h] + 2)).join('┬') + '┐';
const headerSeparator = '├' + headers.map(h => '─'.repeat(columnWidths[h] + 2)).join('┼') + '┤';
const bottomBorder = '└' + headers.map(h => '─'.repeat(columnWidths[h] + 2)).join('┴') + '┘';
// Print top border
console.log(topBorder);
// Print headers
const headerRow = '│ ' + headers.map(h => h.padEnd(columnWidths[h])).join(' │ ') + ' │';
console.log(headerRow);
console.log(headerSeparator);
// Print data rows
data.forEach((row) => {
const values = headers.map((header) => {
const column = columns[header];
let value;
if (column.get && typeof column.get === 'function') {
value = column.get(row);
}
else {
value = row[header];
}
const stringValue = String(value === null || value === undefined ? '' : value);
return stringValue.padEnd(columnWidths[header]);
});
console.log('│ ' + values.join(' │ ') + ' │');
});
// Print bottom border
console.log(bottomBorder);
};
exports.outputPrettyTable = outputPrettyTable;
/**
* Show a hint to users (especially AI assistants) that more data is available with the -r flag
* This makes the -r flag more discoverable for automation and AI use cases
*
* @param itemCount Number of items displayed in the table
* @param item The item object to count total fields from
* @param visibleFields Number of fields shown in the table (default: 4 for title, object, id, url)
*/
function showRawFlagHint(itemCount, item, visibleFields = 4) {
// Count total fields in the item
let totalFields = visibleFields; // Start with the visible fields (title, object, id, url)
if (item) {
// For pages and databases, count properties
if (item.properties) {
totalFields += Object.keys(item.properties).length;
}
// Add other top-level metadata fields
const metadataFields = ['created_time', 'last_edited_time', 'created_by', 'last_edited_by', 'parent', 'archived', 'icon', 'cover'];
metadataFields.forEach(field => {
if (item[field] !== undefined) {
totalFields++;
}
});
}
const hiddenFields = totalFields - visibleFields;
if (hiddenFields > 0) {
const itemText = itemCount === 1 ? 'item' : 'items';
console.log(`\nTip: Showing ${visibleFields} of ${totalFields} fields for ${itemCount} ${itemText}.`);
console.log(`Use -r flag for full JSON output with all properties (recommended for AI assistants and automation).`);
}
}
exports.showRawFlagHint = showRawFlagHint;
const getFilterFields = async (type) => {
switch (type) {
case 'checkbox':
return [{ title: 'equals' }, { title: 'does_not_equal' }];
case 'created_time':
case 'last_edited_time':
case 'date':
return [
{ title: 'after' },
{ title: 'before' },
{ title: 'equals' },
{ title: 'is_empty' },
{ title: 'is_not_empty' },
{ title: 'next_month' },
{ title: 'next_week' },
{ title: 'next_year' },
{ title: 'on_or_after' },
{ title: 'on_or_before' },
{ title: 'past_month' },
{ title: 'past_week' },
{ title: 'past_year' },
{ title: 'this_week' },
];
case 'rich_text':
case 'title':
return [
{ title: 'contains' },
{ title: 'does_not_contain' },
{ title: 'does_not_equal' },
{ title: 'ends_with' },
{ title: 'equals' },
{ title: 'is_empty' },
{ title: 'is_not_empty' },
{ title: 'starts_with' },
];
case 'number':
return [
{ title: 'equals' },
{ title: 'does_not_equal' },
{ title: 'greater_than' },
{ title: 'greater_than_or_equal_to' },
{ title: 'less_than' },
{ title: 'less_than_or_equal_to' },
{ title: 'is_empty' },
{ title: 'is_not_empty' },
];
case 'select':
return [
{ title: 'equals' },
{ title: 'does_not_equal' },
{ title: 'is_empty' },
{ title: 'is_not_empty' },
];
case 'multi_select':
case 'relation':
return [
{ title: 'contains' },
{ title: 'does_not_contain' },
{ title: 'is_empty' },
{ title: 'is_not_empty' },
];
case 'status':
return [
{ title: 'equals' },
{ title: 'does_not_equal' },
{ title: 'is_empty' },
{ title: 'is_not_empty' },
];
case 'files':
case 'formula':
case 'people':
case 'rollup':
default:
console.error(`type: ${type} is not support type`);
return null;
}
};
exports.getFilterFields = getFilterFields;
const buildDatabaseQueryFilter = async (name, type, field, value) => {
let filter = null;
switch (type) {
case 'checkbox':
filter = {
property: name,
[type]: {
// boolean value
[field]: value == 'true',
},
};
break;
case 'date':
case 'created_time':
case 'last_edited_time':
case 'rich_text':
case 'number':
case 'select':
case 'status':
case 'title':
filter = {
property: name,
[type]: {
[field]: value,
},
};
break;
case 'multi_select':
case 'relation': {
const values = value;
if (values.length == 1) {
filter = {
property: name,
[type]: {
[field]: value[0],
},
};
}
else {
filter = { and: [] };
for (const v of values) {
filter.and.push({
property: name,
[type]: {
[field]: v,
},
});
}
}
break;
}
case 'files':
case 'formula':
case 'people':
case 'rollup':
default:
console.error(`type: ${type} is not support type`);
}
return filter;
};
exports.buildDatabaseQueryFilter = buildDatabaseQueryFilter;
const buildPagePropUpdateData = async (name, type, value) => {
switch (type) {
case 'number':
return {
[name]: {
[type]: value,
},
};
case 'select':
return {
[name]: {
[type]: {
name: value,
},
},
};
case 'multi_select': {
const nameObjects = [];
for (const val of value) {
nameObjects.push({
name: val,
});
}
return {
[name]: {
[type]: nameObjects,
},
};
}
case 'relation': {
const relationPageIds = [];
for (const id of value) {
relationPageIds.push({ id: id });
}
return {
[name]: {
[type]: relationPageIds,
},
};
}
}
return null;
};
exports.buildPagePropUpdateData = buildPagePropUpdateData;
const buildOneDepthJson = async (pages) => {
const oneDepthJson = [];
const relationJson = [];
for (const page of pages) {
if (page.object != 'page') {
continue;
}
if (!(0, client_1.isFullPage)(page)) {
continue;
}
const pageData = {};
pageData['page_id'] = page.id;
Object.entries(page.properties).forEach(([key, prop]) => {
switch (prop.type) {
case 'number':
pageData[key] = prop.number;
break;
case 'select':
pageData[key] = prop.select === null ? '' : prop.select.name;
break;
case 'multi_select': {
const multiSelects = [];
for (const select of prop.multi_select) {
multiSelects.push(select.name);
}
pageData[key] = multiSelects.join(',');
break;
}
case 'relation': {
const relationPages = [];
// relationJsonにkeyがなければ作成
if (relationJson[key] == null) {
relationJson[key] = [];
}
for (const relation of prop.relation) {
relationPages.push(relation.id);
relationJson[key].push({
page_id: page.id,
relation_page_id: relation.id,
});
}
pageData[key] = relationPages.join(',');
break;
}
case 'created_time':
pageData[key] = prop.created_time;
break;
case 'last_edited_time':
pageData[key] = prop.last_edited_time;
break;
case 'formula':
switch (prop.formula.type) {
case 'string':
pageData[key] = prop.formula.string;
break;
case 'number':
pageData[key] = prop.formula.number;
break;
case 'boolean':
pageData[key] = prop.formula.boolean;
break;
case 'date':
pageData[key] = prop.formula.date.start;
break;
default:
// console.error(`${prop.formula.type} is not supported`)
}
break;
case 'url':
pageData[key] = prop.url;
break;
case 'date':
pageData[key] = prop.date === null ? '' : prop.date.start;
break;
case 'email':
pageData[key] = prop.email;
break;
case 'phone_number':
pageData[key] = prop.phone_number;
break;
case 'created_by':
pageData[key] = prop.created_by.id;
break;
case 'last_edited_by':
pageData[key] = prop.last_edited_by.id;
break;
case 'people': {
const people = [];
for (const person of prop.people) {
people.push(person.id);
}
pageData[key] = people.join(',');
break;
}
case 'files': {
const files = [];
for (const file of prop.files) {
files.push(file.name);
}
pageData[key] = files.join(',');
break;
}
case 'checkbox':
pageData[key] = prop.checkbox;
break;
case 'unique_id':
pageData[key] = `${prop.unique_id.prefix}-${prop.unique_id.number}`;
break;
case 'title':
pageData[key] = prop.title[0].plain_text;
break;
case 'rich_text': {
const richTexts = [];
for (const richText of prop.rich_text) {
richTexts.push(richText.plain_text);
}
pageData[key] = richTexts.join(',');
break;
}
case 'status':
pageData[key] = prop.status === null ? '' : prop.status.name;
break;
default:
console.error(`${key}(type: ${prop.type}) is not supported`);
}
});
oneDepthJson.push(pageData);
}
return { oneDepthJson, relationJson };
};
exports.buildOneDepthJson = buildOneDepthJson;
const getDbTitle = (row) => {
if (row.title && row.title.length > 0) {
return row.title[0].plain_text;
}
return 'Untitled';
};
exports.getDbTitle = getDbTitle;
const getDataSourceTitle = (row) => {
// Check if it's a full data source response
if ((0, client_1.isFullDataSource)(row)) {
if (row.title && row.title.length > 0) {
return row.title[0].plain_text;
}
}
return 'Untitled';
};
exports.getDataSourceTitle = getDataSourceTitle;
const getPageTitle = (row) => {
let title = 'Untitled';
Object.entries(row.properties).find(([, prop]) => {
if (prop.type === 'title' && prop.title.length > 0) {
title = prop.title[0].plain_text;
return true;
}
});
return title;
};
exports.getPageTitle = getPageTitle;
const getBlockPlainText = (row) => {
try {
switch (row.type) {
case 'bookmark':
return row[row.type].url;
case 'breadcrumb':
return '';
case 'child_database':
return row[row.type].title;
case 'child_page':
return row[row.type].title;
case 'column_list':
return '';
case 'divider':
return '';
case 'embed':
return row[row.type].url;
case 'equation':
return row[row.type].expression;
case 'file':
case 'image':
if (row[row.type].type == 'file') {
return row[row.type].file.url;
}
else {
return row[row.type].external.url;
}
case 'link_preview':
return row[row.type].url;
case 'synced_block':
return '';
case 'table_of_contents':
return '';
case 'table':
return '';
case 'bulleted_list_item':
case 'callout':
case 'code':
case 'heading_1':
case 'heading_2':
case 'heading_3':
case 'numbered_list_item':
case 'paragraph':
case 'quote':
case 'to_do':
case 'toggle': {
let plainText = '';
if (row[row.type].rich_text.length > 0) {
plainText = row[row.type].rich_text[0].plain_text;
}
return plainText;
}
default:
return row[row.type];
}
}
catch (e) {
console.error(`${row.type} is not supported`);
console.error(e);
return '';
}
};
exports.getBlockPlainText = getBlockPlainText;
/**
* Helper to create rich text array from plain text string
*/
const createRichText = (text) => {
return [
{
type: 'text',
text: {
content: text,
},
},
];
};
/**
* Build block JSON from simple text-based flags
* Returns an array of block objects ready for Notion API
*/
const buildBlocksFromTextFlags = (flags) => {
const blocks = [];
if (flags.text) {
blocks.push({
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: createRichText(flags.text),
},
});
}
if (flags.heading1) {
blocks.push({
object: 'block',
type: 'heading_1',
heading_1: {
rich_text: createRichText(flags.heading1),
},
});
}
if (flags.heading2) {
blocks.push({
object: 'block',
type: 'heading_2',
heading_2: {
rich_text: createRichText(flags.heading2),
},
});
}
if (flags.heading3) {
blocks.push({
object: 'block',
type: 'heading_3',
heading_3: {
rich_text: createRichText(flags.heading3),
},
});
}
if (flags.bullet) {
blocks.push({
object: 'block',
type: 'bulleted_list_item',
bulleted_list_item: {
rich_text: createRichText(flags.bullet),
},
});
}
if (flags.numbered) {
blocks.push({
object: 'block',
type: 'numbered_list_item',
numbered_list_item: {
rich_text: createRichText(flags.numbered),
},
});
}
if (flags.todo) {
blocks.push({
object: 'block',
type: 'to_do',
to_do: {
rich_text: createRichText(flags.todo),
checked: false,
},
});
}
if (flags.toggle) {
blocks.push({
object: 'block',
type: 'toggle',
toggle: {
rich_text: createRichText(flags.toggle),
},
});
}
if (flags.code) {
blocks.push({
object: 'block',
type: 'code',
code: {
rich_text: createRichText(flags.code),
language: flags.language || 'plain text',
},
});
}
if (flags.quote) {
blocks.push({
object: 'block',
type: 'quote',
quote: {
rich_text: createRichText(flags.quote),
},
});
}
if (flags.callout) {
blocks.push({
object: 'block',
type: 'callout',
callout: {
rich_text: createRichText(flags.callout),
icon: {
type: 'emoji',
emoji: '💡',
},
},
});
}
return blocks;
};
exports.buildBlocksFromTextFlags = buildBlocksFromTextFlags;
/**
* Attempt to enrich a child_database block with its queryable data_source_id
*
* The Notion API returns child_database blocks without the database/data_source ID,
* making them unqueryable. This function attempts to resolve the block ID to a
* queryable data_source_id by trying to retrieve it as a data source.
*
* @param block The child_database block to enrich
* @returns The enriched block with data_source_id and database_id fields, or original block if resolution fails
*/
const enrichChildDatabaseBlock = async (block) => {
// Only process child_database blocks
if (block.type !== 'child_database') {
return block;
}
try {
// Attempt to use the block ID as a data source ID
// In many cases, the child_database block ID IS the data source ID
const dataSource = await notion.retrieveDataSource(block.id);
// If successful, add the IDs to the block object
return {
...block,
child_database: {
...block.child_database,
// @ts-expect-error - Legacy type compatibility issue - Adding custom fields for discoverability
data_source_id: block.id,
database_id: dataSource.id,
},
};
}
catch {
// If retrieval fails, return the original block unchanged
// This is expected for some child_database blocks
return block;
}
};
exports.enrichChildDatabaseBlock = enrichChildDatabaseBlock;
/**
* Get all child_database blocks from a list of blocks and enrich them with queryable IDs
*
* @param blocks Array of blocks to filter and enrich
* @returns Array of enriched child_database blocks with title, block_id, data_source_id, and database_id
*/
const getChildDatabasesWithIds = async (blocks) => {
const childDatabases = blocks.filter(block => (0, client_1.isFullBlock)(block) && block.type === 'child_database');
const enrichedDatabases = await Promise.all(childDatabases.map(async (block) => {
const enriched = await (0, exports.enrichChildDatabaseBlock)(block);
// Type guard to ensure we have a full block with child_database property
if (!(0, client_1.isFullBlock)(enriched) || enriched.type !== 'child_database') {
return {
block_id: enriched.id,
title: 'Untitled',
data_source_id: null,
database_id: null,
};
}
return {
block_id: enriched.id,
title: enriched.child_database.title,
// @ts-expect-error - Legacy type compatibility issue - Custom fields added by enrichChildDatabaseBlock
data_source_id: enriched.child_database.data_source_id || null,
// @ts-expect-error - Legacy type compatibility issue
database_id: enriched.child_database.database_id || null,
};
}));
return enrichedDatabases;
};
exports.getChildDatabasesWithIds = getChildDatabasesWithIds;
/**
* Build block update content from simple text flags
* Returns an object with the block type properties for updating
*/
const buildBlockUpdateFromTextFlags = (blockType, flags) => {
// For updates, we need to know the block type and provide the appropriate content
// The text flags can update any compatible block type
if (flags.text) {
return {
paragraph: {
rich_text: createRichText(flags.text),
},
};
}
if (flags.heading1) {
return {
heading_1: {
rich_text: createRichText(flags.heading1),
},
};
}
if (flags.heading2) {
return {
heading_2: {
rich_text: createRichText(flags.heading2),
},
};
}
if (flags.heading3) {
return {
heading_3: {
rich_text: createRichText(flags.heading3),
},
};
}
if (flags.bullet) {
return {
bulleted_list_item: {
rich_text: createRichText(flags.bullet),
},
};
}
if (flags.numbered) {
return {
numbered_list_item: {
rich_text: createRichText(flags.numbered),
},
};
}
if (flags.todo) {
return {
to_do: {
rich_text: createRichText(flags.todo),
},
};
}
if (flags.toggle) {
return {
toggle: {
rich_text: createRichText(flags.toggle),
},
};
}
if (flags.code) {
return {
code: {
rich_text: createRichText(flags.code),
language: flags.language || 'plain text',
},
};
}
if (flags.quote) {
return {
quote: {
rich_text: createRichText(flags.quote),
},
};
}
if (flags.callout) {
return {
callout: {
rich_text: createRichText(flags.callout),
},
};
}
return null;
};
exports.buildBlockUpdateFromTextFlags = buildBlockUpdateFromTextFlags;