UNPKG

@jitl/notion-api

Version:

The missing companion library for the official Notion public API.

446 lines 17.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ensureAssetInDirectory = exports.ensureEmojiCopied = exports.ensureImageDownloaded = exports.DOWNLOAD_HTTP_ERROR = exports.DOWNLOAD_PERMISSION_ERROR = exports.getAssetKey = exports.performAssetRequest = exports.parseAssetRequestUrl = exports.parseAssetRequestQuery = exports.ASSET_REQUEST_LAST_EDITED_TIME_PARAM = exports.ASSET_REQUEST_QUERY_PATH_PARAM = exports.getAssetRequestPathname = exports.getAssetRequestUrl = exports.getAssetRequestKey = void 0; const tslib_1 = require("tslib"); /** * This file contains types for working with assets (aka, "file objects") from * the Notion public API. * @category Asset * @module */ const https = tslib_1.__importStar(require("https")); const fs = tslib_1.__importStar(require("fs")); const mimeTypes = tslib_1.__importStar(require("mime-types")); const path = tslib_1.__importStar(require("path")); const crypto = tslib_1.__importStar(require("crypto")); const __1 = require(".."); const fsPromises = fs.promises; const emoji_unicode_1 = tslib_1.__importDefault(require("emoji-unicode")); const util_1 = require("@jitl/util"); const fast_safe_stringify_1 = tslib_1.__importDefault(require("fast-safe-stringify")); const cache_1 = require("./cache"); const DEBUG_ASSET = __1.DEBUG.extend('asset'); const DUMMY_URL = new URL('https://example.com'); /** * Get a unique string key for de-duplicating [[AssetRequest]]s * @category Asset */ function getAssetRequestKey(assetRequest) { const url = getAssetRequestUrl(assetRequest, DUMMY_URL, undefined); for (const [key, val] of url.searchParams) { url.searchParams.set(key, hashString(val)); } const path = url.pathname.slice(1).replace(/\//g, '.'); const rest = url.searchParams.toString() === '' ? '' : `.${hashString(url.searchParams.toString())}`; return path + rest; } exports.getAssetRequestKey = getAssetRequestKey; /** * Build a URL to GET an asset. * @param baseUrl The base URL where the asset request handler is mounted (ending with a /), eg `https://mydomain.com/api/notion-assets/`. * @category Asset */ function getAssetRequestUrl(assetRequest, baseUrl, last_edited_time) { const { object, id, field, ...rest } = assetRequest; const url = new URL(`${object}/${id}/${field}`, baseUrl); const paramKeys = (0, util_1.objectKeys)(rest); for (const key of paramKeys) { url.searchParams.set(key, fast_safe_stringify_1.default.stable(rest[key])); } url.searchParams.sort(); if (last_edited_time) { url.searchParams.set(exports.ASSET_REQUEST_LAST_EDITED_TIME_PARAM, last_edited_time); } return url; } exports.getAssetRequestUrl = getAssetRequestUrl; /** * Get an absolute pathname (eg `/api/notion-assets/...`) for the given asset request. * @param assetRequest The asset request. * @param basePathOrURL Eg `/api/notion-assets/`. A path or URL ending with a '/'. * @param last_edited_time The last_edited_time of the object that contains this asset, for immutable caching. * @category Asset */ function getAssetRequestPathname(assetRequest, basePathOrURL, last_edited_time) { const baseURL = new URL(basePathOrURL, DUMMY_URL); const url = getAssetRequestUrl(assetRequest, baseURL, last_edited_time); return url.pathname + url.search; } exports.getAssetRequestPathname = getAssetRequestPathname; const NOT_SLASH = '[^/]'; const SLASH = '\\/'; const URL_TRIPLE = new RegExp(`^(?<object>${NOT_SLASH}+)${SLASH}(?<id>${NOT_SLASH}+)${SLASH}(?<field>${NOT_SLASH}+)${SLASH}?$`); /** @category Asset */ exports.ASSET_REQUEST_QUERY_PATH_PARAM = 'asset_request'; /** @category Asset */ exports.ASSET_REQUEST_LAST_EDITED_TIME_PARAM = 'last_edited_time'; /** * Parse an AssetRequest from a NextJS-style query object. * @category Asset */ function parseAssetRequestQuery(query) { const assetRequestParts = query[exports.ASSET_REQUEST_QUERY_PATH_PARAM]; if (!query[exports.ASSET_REQUEST_QUERY_PATH_PARAM]) { throw new Error(`Missing ${exports.ASSET_REQUEST_QUERY_PATH_PARAM} query param`); } if (!(Array.isArray(assetRequestParts) && assetRequestParts.length === 3)) { throw new Error(`${exports.ASSET_REQUEST_QUERY_PATH_PARAM} query param must be [object, id, field]`); } const last_edited_time = query[exports.ASSET_REQUEST_LAST_EDITED_TIME_PARAM]; const [object, id, field] = assetRequestParts; const result = { object, id, field }; for (const [key, values] of (0, util_1.objectEntries)(query)) { const val = Array.isArray(values) ? values[0] || '' : values; if (key !== exports.ASSET_REQUEST_LAST_EDITED_TIME_PARAM && key !== exports.ASSET_REQUEST_QUERY_PATH_PARAM) { try { result[key] = JSON.parse(val); } catch (error) { console.warn(`Warning: invalid JSON in query param ${key}=${val}`); } } } return { assetRequest: result, last_edited_time: typeof last_edited_time === 'string' ? last_edited_time : undefined, }; } exports.parseAssetRequestQuery = parseAssetRequestQuery; /** * Inverse of [[getAssetRequestUrl]]. * @category Asset */ function parseAssetRequestUrl(assetUrl, baseURL) { const url = assetUrl instanceof URL ? assetUrl : new URL(assetUrl, baseURL); const base = new URL(baseURL); const chopped = url.pathname.slice(base.pathname.length); const match = chopped.match(URL_TRIPLE); if (!match) { throw new Error(`Failed to parse AssetRequest from URL suffix: ${JSON.stringify(chopped)} (asset ${assetUrl}, base ${baseURL}, regex ${URL_TRIPLE})`); } (0, util_1.assertDefined)(match.groups); const { object, id, field } = match.groups; (0, util_1.assertDefined)(object); (0, util_1.assertDefined)(id); (0, util_1.assertDefined)(field); const query = { [exports.ASSET_REQUEST_QUERY_PATH_PARAM]: [object, id, field], }; for (const [key, val] of url.searchParams) { query[key] = val; } return parseAssetRequestQuery(query); } exports.parseAssetRequestUrl = parseAssetRequestUrl; const ObjectLookup = { block: async ({ cache, cacheBehavior, request, notion }) => { const [block, hit] = await (0, cache_1.getFromCache)(cacheBehavior, () => cache.block.get(request.id), () => notion.blocks.retrieve({ block_id: request.id })); DEBUG_ASSET('lookup block %s: %s', request.id, hit ? 'hit' : 'miss'); if ('type' in block) { (0, cache_1.fillCache)(cacheBehavior, () => cache.addBlock(block, undefined)); return block; } DEBUG_ASSET('lookup block %s: not enough data', request.id); }, page: async ({ cache, cacheBehavior, request, notion }) => { const [page, hit] = await (0, cache_1.getFromCache)(cacheBehavior, () => cache.page.get(request.id), () => notion.pages.retrieve({ page_id: request.id })); DEBUG_ASSET('lookup page %s: %s', request.id, hit ? 'hit' : 'miss'); if ((0, __1.isFullPage)(page)) { (0, cache_1.fillCache)(cacheBehavior, () => cache.addPage(page)); return page; } DEBUG_ASSET('lookup page %s: not enough data', request.id); }, user: async ({ cache, request, notion }) => { const user = await notion.users.retrieve({ user_id: request.id }); return user; }, }; const AssetHandlers = { block: { icon: async (args) => { const block = await ObjectLookup.block(args); if (block) { const blockData = (0, __1.getBlockData)(block); if ('icon' in blockData) { return blockData.icon || undefined; } } }, image: async (args) => { const block = await ObjectLookup.block(args); if (block && block.type === 'image') { return block.image; } }, file: async (args) => { const block = await ObjectLookup.block(args); switch (block === null || block === void 0 ? void 0 : block.type) { case 'audio': case 'video': case 'pdf': case 'file': return (0, __1.getBlockData)(block); } }, }, page: { cover: async (args) => { const page = await ObjectLookup.page(args); if (page) { return page.cover || undefined; } }, icon: async (args) => { const page = await ObjectLookup.page(args); if (page) { return page.icon || undefined; } }, properties: async (args) => { const page = await ObjectLookup.page(args); if (!page) { return; } const property = (0, __1.getProperty)(page, args.request.property); if (!property || property.type !== 'files') { return; } const index = args.request.propertyIndex || 0; const file = property.files[index]; // Convert file to asset if (file && 'external' in file) { return { type: 'external', external: file.external, }; } if (file && 'file' in file) { return { type: 'file', file: file.file, }; } }, }, user: { avatar_url: async (args) => { const user = await ObjectLookup.user(args); if (user && user.avatar_url) { // User URLs are public, so we can essentially just treat them like an external file. return { type: 'external', external: { url: user.avatar_url, }, }; } }, }, }; /** * Look up an asset from the Notion API. * @category Asset */ async function performAssetRequest(args) { const { request } = args; let result; switch (request.object) { case 'page': result = AssetHandlers.page[request.field](args); break; case 'block': result = AssetHandlers.block[request.field](args); break; case 'user': result = AssetHandlers.user[request.field](args); break; default: (0, util_1.unreachable)(request); } const asset = await result; DEBUG_ASSET('request %s --> %s', getAssetRequestKey(request), asset && getAssetKey(asset)); return asset; } exports.performAssetRequest = performAssetRequest; //////////////////////////////////////////////////////////////////////////////// // Asset downloading //////////////////////////////////////////////////////////////////////////////// /** * @returns a string key unique for the asset, suitable for use in a hashmap, cache, or filename. * @category Asset */ function getAssetKey(asset) { if (asset.type === 'file') { const url = asset.file.url; const urlHash = hashNotionAssetUrl(url); return `file.${urlHash}`; } if (asset.type === 'external') { const url = asset.external.url; const urlHash = hashString(url); return `external.${urlHash}`; } if (asset.type === 'emoji') { const codepoints = (0, emoji_unicode_1.default)(asset.emoji).split(' ').join('-'); return `emoji.${codepoints}`; } (0, util_1.unreachable)(asset); } exports.getAssetKey = getAssetKey; /** * [[Error.name]] of errors thrown by [[ensureImageDownloaded]] when * encountering a permission error, eg if the asset is expired. * @category Asset */ exports.DOWNLOAD_PERMISSION_ERROR = 'DownloadPermissionError'; /** * [[Error.name]] of errors thrown by [[ensureImageDownloaded]] when * encountering other HTTP error codes. * @category Asset */ exports.DOWNLOAD_HTTP_ERROR = 'DownloadHTTPError'; /** * Download image at `url` to a path in `directory` starting with * `filenamePrefix` if it does not exist, or return the existing path on disk * that has that prefix. * * @returns Promise<string> Relative path from `directory` to image on disk. * @category Asset */ async function ensureImageDownloaded(args) { const { url, filenamePrefix, directory, cacheBehavior } = args; const files = await fsPromises.readdir(directory); const filename = files.find((name) => name.startsWith(filenamePrefix)); // Found if (filename && cacheBehavior !== 'refresh') { DEBUG_ASSET('found %s as %s', filenamePrefix, filename); return filename; } if (cacheBehavior === 'read-only') { return undefined; } return new Promise((resolve, reject) => { https.get(url, (res) => { if (res.statusCode && res.statusCode >= 400 && res.statusCode <= 599) { const permissionError = res.statusCode >= 401 && res.statusCode <= 403; DEBUG_ASSET('download %s (%d %s): error', url, res.statusCode, res.statusMessage); const error = Object.assign(new Error(`Image download failed: HTTP ${res.statusCode}: ${res.statusMessage}`), { name: permissionError ? exports.DOWNLOAD_PERMISSION_ERROR : exports.DOWNLOAD_HTTP_ERROR, code: res.statusCode, statusMessage: res.statusMessage, url, }); reject(error); return; } const ext = mimeTypes.extension(res.headers['content-type'] || 'image/png'); const dest = `${filenamePrefix}.${ext}`; DEBUG_ASSET('download %s (%d %s) --> %s', url, res.statusCode, res.statusMessage, dest); const destStream = fs.createWriteStream(path.join(directory, dest)); res .pipe(destStream) .on('finish', () => { resolve(dest); }) .on('error', reject); }); }); } exports.ensureImageDownloaded = ensureImageDownloaded; /** * Copy an emoji image for `emoji` into `directory`. * @returns relative path from `directory` to the image. * @category Asset */ async function ensureEmojiCopied(args) { const { emoji, directory, filenamePrefix, cacheBehavior } = args; let emojiSourceDirectory = args.emojiSourceDirectory; if (emojiSourceDirectory === undefined) { let emojiPackageRoot; try { const emojiPackageMain = require.resolve('emoji-datasource-apple'); if (typeof emojiPackageMain !== 'string') { throw new Error('Cannot use require.resolve to locate emoji-datasource-apple: resolve returned a module ID instead of a filesystem path'); } emojiPackageRoot = path.dirname(emojiPackageMain); } catch (error) { emojiPackageRoot = path.join(process.cwd(), 'node_modules', 'emoji-datasource-apple'); } emojiSourceDirectory = path.join(emojiPackageRoot, 'img', 'apple', '64'); } const codepoints = (0, emoji_unicode_1.default)(emoji).split(' ').join('-'); const source = path.join(emojiSourceDirectory, `${codepoints}.png`); const destinationBasename = `${filenamePrefix}.png`; const destination = path.join(directory, destinationBasename); if (cacheBehavior !== 'refresh' && fs.existsSync(destination)) { DEBUG_ASSET('found emoji %s as %s', emoji, destination); return destinationBasename; } if (cacheBehavior === 'read-only') { return undefined; } if (!fs.existsSync(source)) { console.warn(`Emoji not found: ${emoji} (path ${source})`); return undefined; } DEBUG_ASSET('copy emoji %s %s --> %s', emoji, source, destination); await fsPromises.copyFile(source, destination); return destinationBasename; } exports.ensureEmojiCopied = ensureEmojiCopied; /** * Ensure `asset` is present on disk in `directory`. * @returns Relative path from `directory` to the asset on disk, or undefined. * @category Asset */ async function ensureAssetInDirectory(args) { const { asset, directory, cacheBehavior } = args; const key = getAssetKey(asset); if (asset.type === 'file') { const url = asset.file.url; return ensureImageDownloaded({ url, directory, filenamePrefix: key, cacheBehavior, }); } if (asset.type === 'external') { const url = asset.external.url; return ensureImageDownloaded({ url, directory, filenamePrefix: key, cacheBehavior, }); } if (asset.type === 'emoji') { return ensureEmojiCopied({ emoji: asset.emoji, directory, filenamePrefix: key, cacheBehavior, }); } (0, util_1.unreachable)(asset); } exports.ensureAssetInDirectory = ensureAssetInDirectory; /** * Notion file assets are time-expiring signed S3 URLs. This function strips the * signature to make a stable hash. * @category Asset */ function hashNotionAssetUrl(input) { const url = new URL(input); // Notion assets have an AWS temporary signature that we should remove in // order to remain stable over time. url.search = ''; url.hash = ''; return hashString(url.toString()); } function hashString(input) { return crypto.createHash('md5').update(input).digest('hex'); } //# sourceMappingURL=assets.js.map