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