UNPKG

notion-helper

Version:

A library of functions for working more easily with the Notion API

764 lines (686 loc) 27.7 kB
import CONSTANTS from "./constants.mjs"; import { enforceStringLength, isValidURL, isValidUUID, validateStringLength, validateDate } from "./utils.mjs"; const LOG_PREFIX = "buildRichTextObj"; const PREVIEW_MAX_LENGTH = 200; const DEFAULT_OVERFLOW_STRATEGY = "split"; const DEFAULT_INVALID_URL_STRATEGY = "warn"; const DEFAULT_INVALID_MENTION_STRATEGY = "warn"; const OVERFLOW_STRATEGIES = new Set(["split", "truncate", "throw"]); const INVALID_URL_STRATEGIES = new Set(["warn", "strip", "throw"]); const INVALID_MENTION_STRATEGIES = new Set(["warn", "strip", "throw"]); const ALLOWED_ANNOTATION_KEYS = new Set([ "bold", "italic", "underline", "strikethrough", "code", "color", ]); /** * Builds a Rich Text Object. See: https://developers.notion.com/reference/rich-text * @param {(string|Object)} input - The text content or input object. If string, the input can be normal text or an equation. If object, it can be a text, equation, or mention object. * @param {Object} [options] - Options for configuring the rich text object * @param {Object} [options.annotations] - Options for the Annotation object * @param {boolean} [options.annotations.bold] - Bold text * @param {boolean} [options.annotations.italic] - Italic text * @param {boolean} [options.annotations.strikethrough] - Strikethrough text * @param {boolean} [options.annotations.underline] - Underlined text * @param {boolean} [options.annotations.code] - Code-style text * @param {string} [options.annotations.color] - String specifying the text's color or background color. Options: "blue", "brown", "default", "gray", "green", "orange", "pink", "purple", "red", "yellow". All except "default" can also be used as a background color with "[color]_background" - example: "blue_background". See: https://developers.notion.com/reference/rich-text#the-annotation-object * @param {string} [options.url] - The URL for this object, if any. Creates a clickable link. * @param {string} [options.type="text"] - An optional type for the Rich Text Object. Supports text, equation, and mention. * @returns {Array<Object>} - Array with a single Rich Text Object * * @example * // Simple text * buildRichTextObj("Hello World") * * // Text with URL * buildRichTextObj("Watch this very important video", { url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }) * * // Text with annotations * buildRichTextObj("Bold and brown", { * annotations: { bold: true, color: "brown" } * }) * * // Text with URL and annotations * buildRichTextObj("Bold blue link", { * url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", * annotations: { bold: true, color: "blue" } * }) * * // Equation * buildRichTextObj("E = mc^2", { type: "equation" }) * * // Mention * buildRichTextObj({ type: "user", user: { id: "user_id" } }, { type: "mention" }) * buildRichTextObj({ type: "date", date: { start: "2025-01-01" } }, { type: "mention" }) * buildRichTextObj({ type: "database", database_id: "database_id" }, { type: "mention" }) * buildRichTextObj({ type: "page", page_id: "page_id" }, { type: "mention" }) */ export function buildRichTextObj(input, options = {}) { const isLegacyAnnotationsObject = arguments.length > 1 && options && typeof options === "object" && !Array.isArray(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => ALLOWED_ANNOTATION_KEYS.has(key)); if (isLegacyAnnotationsObject) { options = { annotations: options, url: arguments[2], type: arguments[3] || "text", }; } const { annotations = {}, url, type = "text", overflow = DEFAULT_OVERFLOW_STRATEGY, onInvalidUrl = DEFAULT_INVALID_URL_STRATEGY, onInvalidMentionId = DEFAULT_INVALID_MENTION_STRATEGY, } = options; const overflowStrategy = normalizeStrategy(overflow, OVERFLOW_STRATEGIES, DEFAULT_OVERFLOW_STRATEGY); const invalidUrlStrategy = normalizeStrategy(onInvalidUrl, INVALID_URL_STRATEGIES, DEFAULT_INVALID_URL_STRATEGY); const invalidMentionStrategy = normalizeStrategy(onInvalidMentionId, INVALID_MENTION_STRATEGIES, DEFAULT_INVALID_MENTION_STRATEGY); const sanitizedAnnotations = sanitizeAnnotations(annotations); let resolvedUrl = url ?? null; if (resolvedUrl) { resolvedUrl = sanitizeUrl(resolvedUrl, invalidUrlStrategy, input); } if (typeof input === "string") { const limit = getMaxLengthForType(type); const chunks = applyOverflowStrategy(input, { limit, strategy: overflowStrategy, type, }); switch (type) { case "text": return chunks.map((content) => ({ type: "text", text: { content, link: resolvedUrl ? { url: resolvedUrl } : null, }, annotations: { ...sanitizedAnnotations, }, ...(resolvedUrl ? { href: resolvedUrl } : {}), })); case "equation": return chunks.map((expression) => ({ type: "equation", equation: { expression, }, annotations: { ...sanitizedAnnotations, }, ...(resolvedUrl ? { href: resolvedUrl } : {}), })); default: break; } } if (typeof input === "object") { if (type === "text" || !type) { return [ { type: "text", text: input, }, ]; } switch (type) { case "equation": { const expression = typeof input?.expression === "string" ? input.expression : null; if (expression) { const chunks = applyOverflowStrategy(expression, { limit: getMaxLengthForType("equation"), strategy: overflowStrategy, type: "equation", }); return chunks.map((segment) => ({ type: "equation", equation: { ...input, expression: segment, }, annotations: { ...annotations, }, ...(resolvedUrl ? { href: resolvedUrl } : {}), })); } return [ { type: "equation", equation: { ...input, }, annotations: { ...sanitizedAnnotations, }, ...(resolvedUrl ? { href: resolvedUrl } : {}), }, ]; } case "mention": return processMentionInput(input, { annotations: sanitizedAnnotations, url: resolvedUrl, onInvalidMentionId: invalidMentionStrategy, overflow: overflowStrategy, onInvalidUrl: invalidUrlStrategy, }); default: { const error = `Unsupported rich text type: ${type}`; console.error(error); throw new Error(error); } } } const error = `Invalid input sent to buildRichTextObj()`; console.error(error); throw new Error(error); } /** * Creates a user mention with shorthand syntax. * @param {string} userId - The user ID to mention * @param {Object} [options] - Additional options for the mention * @param {Object} [options.annotations] - Text annotations (bold, italic, etc.) * @param {string} [options.url] - URL for the mention * @returns {Array<Object>} - Array with a single Rich Text Object containing the user mention * * @example * // Simple user mention * mentionUser("user_123") * * // User mention with annotations * mentionUser("user_123", { annotations: { bold: true, color: "blue" } }) */ export function mentionUser(userId, options = {}) { return buildRichTextObj( { type: "user", user: { id: userId } }, { type: "mention", ...options } ); } /** * Creates a date mention with shorthand syntax. * @param {string|Object} date - The date string or date object * @param {Object} [options] - Additional options for the mention * @param {Object} [options.annotations] - Text annotations (bold, italic, etc.) * @param {string} [options.url] - URL for the mention * @returns {Array<Object>} - Array with a single Rich Text Object containing the date mention * * @example * // Simple date mention * mentionDate("2025-01-01") * * // Date mention with time range * mentionDate({ start: "2025-01-01", end: "2025-01-02" }) * * // Date mention with annotations * mentionDate("2025-01-01", { annotations: { bold: true } }) */ export function mentionDate(date, options = {}) { const dateObj = typeof date === "string" ? { start: date } : date; if (!dateObj || !dateObj.start) { console.warn(`Invalid date. Date: ${date}.`); } if (dateObj.end && !dateObj.start) { console.warn(`Invalid date. Date: ${date}. End date provided without start date.`); } const validatedDateObj = { start: validateDate(dateObj.start), end: dateObj.end ? validateDate(dateObj.end) : null, } return buildRichTextObj( { type: "date", date: validatedDateObj }, { type: "mention", ...options } ); } /** * Creates a database mention with shorthand syntax. * @param {string} databaseId - The database ID to mention * @param {Object} [options] - Additional options for the mention * @param {Object} [options.annotations] - Text annotations (bold, italic, etc.) * @param {string} [options.url] - URL for the mention * @returns {Array<Object>} - Array with a single Rich Text Object containing the database mention * * @example * // Simple database mention * mentionDatabase("database_123") * * // Database mention with annotations * mentionDatabase("database_123", { annotations: { italic: true } }) */ export function mentionDatabase(databaseId, options = {}) { return buildRichTextObj( { type: "database", database: { id: databaseId } }, { type: "mention", ...options } ); } /** * Creates a page mention with shorthand syntax. * @param {string} pageId - The page ID to mention * @param {Object} [options] - Additional options for the mention * @param {Object} [options.annotations] - Text annotations (bold, italic, etc.) * @param {string} [options.url] - URL for the mention * @returns {Array<Object>} - Array with a single Rich Text Object containing the page mention * * @example * // Simple page mention * mentionPage("page_123") * * // Page mention with annotations * mentionPage("page_123", { annotations: { color: "green" } }) */ export function mentionPage(pageId, options = {}) { return buildRichTextObj( { type: "page", page: { id: pageId } }, { type: "mention", ...options } ); } // TODO: Run everything passed to enforceRichText through enforceStringLength /** * Enforces Rich Text format for content. * @param {string|Object|Array} content - The content to be enforced as Rich Text. * @returns {Array} An array of Rich Text Objects. */ export function enforceRichText(content) { if (!content) { return []; } if (Array.isArray(content)) { return content.flatMap((item) => typeof item === "string" ? enforceRichText(item) : enforceRichTextObject(item) ); } if (typeof content === "string") { const strings = enforceStringLength(content); const richTextObjects = strings.flatMap((string) => { const isURL = isValidURL(string); const isBold = /^\*{2}[\s\S]*?\*{2}$/.test(string); const isItalic = /^[\*_]{1}[^\*_]{1}[\s\S]*?[^\*_]{1}[\*_]{1}$/.test(string); const isBoldItalic = /^\*{3}[\s\S]*?\*{3}$/.test(string); let plainString = string; if (isBold || isItalic || isBoldItalic) { plainString = string.replace(/^(\*|_)+|(\*|_)+$/g, ""); } return buildRichTextObj( plainString, { annotations: { bold: isBold || isBoldItalic, italic: isItalic || isBoldItalic, }, url: isURL ? plainString : null, } ); }); return richTextObjects; } if (typeof content === "number") { return buildRichTextObj(content.toString()); } if (typeof content === "object") { return [enforceRichTextObject(content)]; } console.warn(`Invalid input for rich text. Returning empty array.`); return []; } /** * Enforces a single Rich Text Object format. * @param {string|Object} obj - The object to be enforced as a Rich Text Object. * @returns {Object} A Rich Text Object. */ export function enforceRichTextObject(obj) { if (typeof obj === "string") { return buildRichTextObj(obj)[0]; } // Already-valid Notion rich text objects if (obj?.type === "text" && obj?.text && typeof obj.text.content === "string") { validateStringLength({ string: obj.text.content, type: "text" }); return obj; } if (obj?.type === "equation" && typeof obj?.equation?.expression === "string") { validateStringLength({ string: obj.equation.expression, type: "equation" }); return obj; } if (obj?.type === "mention" && obj?.mention && typeof obj.mention === "object") { validateStringLength({ string: obj.mention.type, type: "mention" }); return obj; } // Shorthand: equation provided as { type: "equation", expression: "..." } if (obj?.type === "equation" && typeof obj?.expression === "string") { validateStringLength({ string: obj.expression, type: "equation" }); return { type: "equation", equation: { expression: obj.expression }, ...(obj.annotations ? { annotations: obj.annotations } : {}), }; } // Shorthand: mentions provided as { type: one of mention subtypes, ...payload } // Note: link_preview and template_mention are read-only (cannot be created via API) const mentionTypes = new Set(["database", "date", "page", "user"]); if (mentionTypes.has(obj?.type)) { const { annotations, type, ...rest } = obj; return { type: "mention", mention: { type, [type]: rest, }, ...(annotations ? { annotations } : {}), }; } console.warn(`Invalid rich text object. Returning empty rich text object.`); return buildRichTextObj("")[0]; } /** * Normalizes a caller-supplied strategy string to a supported value. * Falls back to the provided default when the value is falsy or unrecognized. * * @param {string} value - Raw option value supplied by the developer. * @param {Set<string>} allowedStrategies - Set of recognized strategy strings. * @param {string} fallback - Default strategy to use when value is invalid. * @returns {string} A normalized, supported strategy string. */ function normalizeStrategy(value, allowedStrategies, fallback) { if (!value || typeof value !== "string") { return fallback; } const normalized = value.toLowerCase(); if (allowedStrategies.has(normalized)) { return normalized; } console.warn(`[${LOG_PREFIX}] Unknown strategy "${value}". Falling back to "${fallback}".`); return fallback; } /** * Generates a truncated preview string for logging purposes. * * @param {*} value - The value to preview (coerced to string when necessary). * @param {number} [maxLength=PREVIEW_MAX_LENGTH] - Maximum number of characters to retain. * @returns {string} A truncated preview string suitable for logs. */ function createPreview(value, maxLength = PREVIEW_MAX_LENGTH) { if (value === null || value === undefined) { return ""; } const stringValue = typeof value === "string" ? value : String(value); return stringValue.length > maxLength ? `${stringValue.slice(0, maxLength)}...[truncated]` : stringValue; } /** * Filters an annotations object so only Notion-supported keys are preserved. * * @param {Object} input - Potentially user-supplied annotations. * @returns {Object} A shallow copy containing only safe annotation properties. */ function sanitizeAnnotations(input) { if (!input || typeof input !== "object") { return {}; } const sanitized = {}; for (const key of ALLOWED_ANNOTATION_KEYS) { if (Object.prototype.hasOwnProperty.call(input, key)) { sanitized[key] = input[key]; } } return sanitized; } /** * Resolves the character limit enforced by Notion for a given rich-text type. * * @param {string} type - Rich-text subtype (e.g., "text", "equation", "mention"). * @returns {number} Maximum allowed character count for the type. */ function getMaxLengthForType(type) { switch (type) { case "equation": return CONSTANTS.MAX_EQUATION_LENGTH; case "mention": return CONSTANTS.MAX_MENTION_LENGTH; case "text": default: return CONSTANTS.MAX_TEXT_LENGTH; } } /** * Determines whether a UTF-16 code unit is a high surrogate. * Used to avoid splitting surrogate pairs when chunking long strings. * * @param {number} codePoint - UTF-16 code unit to inspect. * @returns {boolean} True when the code represents a high surrogate. */ function isHighSurrogate(codePoint) { return codePoint >= 0xd800 && codePoint <= 0xdbff; } /** * Splits a string into segments that do not exceed the provided length limit. * Handles surrogate pairs so multi-byte characters are not split mid-way. * * @param {string} value - The string to segment. * @param {number} limit - Maximum length of each segment. * @returns {Array<string>} Array of string segments respecting the limit. */ function splitStringByLimit(value, limit) { if (!limit || limit <= 0) { limit = CONSTANTS.MAX_TEXT_LENGTH; } const segments = []; let index = 0; while (index < value.length) { let end = Math.min(index + limit, value.length); if (end < value.length) { const code = value.charCodeAt(end - 1); if (isHighSurrogate(code)) { end += 1; if (end > value.length) { end = value.length; } } } if (end === index) { end = Math.min(index + limit, value.length); if (end === index) { end = Math.min(index + 1, value.length); } } segments.push(value.slice(index, end)); index = end; } return segments; } /** * Applies the configured overflow strategy to a string value. * Depending on the strategy, the value is split, truncated, or causes an error. * * @param {string} value - User-supplied string content. * @param {Object} params - Strategy configuration. * @param {number} params.limit - Maximum allowed length for the string. * @param {string} params.strategy - Normalized overflow strategy (split|truncate|throw). * @param {string} params.type - Rich-text subtype, used for log context. * @returns {Array<string>} Array of processed string segments. * @throws {Error} When the strategy is "throw" and the value exceeds the limit. */ function applyOverflowStrategy(value, { limit, strategy, type }) { if (typeof value !== "string") { return [value]; } if (value.length <= limit) { return [value]; } const preview = createPreview(value); switch (strategy) { case "split": { const chunks = splitStringByLimit(value, limit); console.warn(`[${LOG_PREFIX}] Input for type "${type}" exceeded ${limit} characters. Strategy: split into ${chunks.length} chunk(s). Preview: ${preview}`); return chunks; } case "truncate": console.warn(`[${LOG_PREFIX}] Input for type "${type}" exceeded ${limit} characters. Strategy: truncate. Preview: ${preview}`); return [value.slice(0, limit)]; case "throw": throw new Error(`[${LOG_PREFIX}] Input for type "${type}" exceeded maximum length (${value.length} > ${limit}). Preview: ${preview}`); default: { console.warn(`[${LOG_PREFIX}] Unknown overflow strategy "${strategy}". Falling back to split.`); const chunks = splitStringByLimit(value, limit); console.warn(`[${LOG_PREFIX}] Input for type "${type}" exceeded ${limit} characters. Strategy: split into ${chunks.length} chunk(s). Preview: ${preview}`); return chunks; } } } /** * Validates a URL according to the configured invalid-URL strategy. * * @param {string} url - Potential URL value supplied by the caller. * @param {string} strategy - Normalized invalid URL strategy (warn|strip|throw). * @param {*} contextPreview - Original input used for log previews. * @returns {string|null} Sanitized URL (null when stripped) or the original URL when valid. * @throws {Error} When strategy is "throw" and the URL is invalid. */ function sanitizeUrl(url, strategy, contextPreview) { if (!url) { return null; } validateStringLength({ string: url, type: "url" }); if (isValidURL(url)) { return url; } const preview = createPreview(contextPreview); switch (strategy) { case "warn": console.warn(`[${LOG_PREFIX}] Invalid URL "${url}". Strategy: warn. Input preview: ${preview}`); return url; case "strip": console.warn(`[${LOG_PREFIX}] Invalid URL "${url}". Strategy: strip (link removed). Input preview: ${preview}`); return null; case "throw": throw new Error(`[${LOG_PREFIX}] Invalid URL "${url}". Strategy: throw. Input preview: ${preview}`); default: console.warn(`[${LOG_PREFIX}] Unknown invalid URL strategy "${strategy}". Defaulting to warn.`); console.warn(`[${LOG_PREFIX}] Invalid URL "${url}". Strategy: warn. Input preview: ${preview}`); return url; } } /** * Extracts a UUID from a mention payload when available. * Supports user, page, and database mention objects. * * @param {Object} mention - Mention object supplied to buildRichTextObj. * @returns {string|null} Extracted ID or null when not applicable. */ function extractMentionId(mention) { if (!mention || typeof mention !== "object") { return null; } switch (mention.type) { case "user": return mention.user?.id ?? null; case "page": return mention.page?.id ?? null; case "database": return mention.database?.id ?? null; default: return null; } } /** * Produces a human-readable fallback string for a mention object. * Stored in the generated rich-text object so the mention still conveys context * even when Notion strips the mention metadata (e.g., due to permissions). * * @param {Object} mention - Mention payload handled by buildRichTextObj. * @returns {string} Fallback plain-text label for the mention. */ function createMentionPlainText(mention) { if (!mention || typeof mention !== "object") { return ""; } switch (mention.type) { case "user": return "@User"; case "page": return "Page"; case "database": return "Database"; case "date": return mention.date?.start ?? "Date"; case "template_mention": return "Template"; default: return "Mention"; } } /** * Builds a mention rich-text object using the configured validation strategies. * Handles invalid IDs (warn/strip/throw) and preserves optional URL behaviour. * * @param {Object} mention - Mention payload supplied by the caller (user/page/etc.). * @param {Object} config - Behaviour configuration forwarded from buildRichTextObj. * @param {Object} config.annotations - Annotation overrides to apply to the mention. * @param {string|null} config.url - Optional URL to apply to the mention wrapper. * @param {string} config.onInvalidMentionId - Strategy for invalid mention IDs. * @param {string} config.overflow - Overflow strategy used when coercing invalid mentions to text. * @param {string} config.onInvalidUrl - Strategy for invalid URLs supplied with mentions. * @returns {Array<Object>} Array containing a single mention-rich text object. */ function processMentionInput(mention, { annotations, url, onInvalidMentionId, overflow, onInvalidUrl, }) { const sanitizedAnnotations = sanitizeAnnotations(annotations); if (!mention || typeof mention !== "object" || typeof mention.type !== "string") { console.warn(`[${LOG_PREFIX}] Invalid mention payload provided. Converting to empty text.`); return buildRichTextObj("", { annotations: sanitizedAnnotations, url, overflow, onInvalidUrl, onInvalidMentionId }); } const mentionId = extractMentionId(mention); if (mentionId && !isValidUUID(mentionId)) { const preview = createPreview(mentionId); switch (onInvalidMentionId) { case "warn": console.warn(`[${LOG_PREFIX}] Invalid ${mention.type} ID "${mentionId}". Strategy: warn. Preview: ${preview}`); break; case "strip": console.warn(`[${LOG_PREFIX}] Invalid ${mention.type} ID "${mentionId}". Strategy: strip (converted to text). Preview: ${preview}`); return buildRichTextObj( `Invalid ${mention.type} mention (${preview})`, { annotations: sanitizedAnnotations, ...(url ? { url } : {}), overflow, onInvalidUrl, onInvalidMentionId, } ); case "throw": throw new Error(`[${LOG_PREFIX}] Invalid ${mention.type} ID "${mentionId}". Strategy: throw. Preview: ${preview}`); default: console.warn(`[${LOG_PREFIX}] Unknown invalid mention strategy "${onInvalidMentionId}". Defaulting to warn.`); console.warn(`[${LOG_PREFIX}] Invalid ${mention.type} ID "${mentionId}". Strategy: warn. Preview: ${preview}`); } } const plainText = createMentionPlainText(mention); return [ { type: "mention", mention, annotations: { ...sanitizedAnnotations, }, ...(plainText ? { plain_text: plainText } : {}), ...(url ? { href: url } : {}), }, ]; }