source/node.ts
/*
* *** MIT LICENSE ***
* -------------------------------------------------------------------------
* This code may be modified and distributed under the MIT license.
* See the LICENSE file for details.
* -------------------------------------------------------------------------
*
* @summary Helpers for handling gatsby's nodes
*
* @author Alvis HT Tang <alvis@hilbert.space>
* @license MIT
* @copyright Copyright (c) 2021 - All Rights Reserved.
* -------------------------------------------------------------------------
*/
import { name } from '#.';
import type { Database, Metadata, Page } from '#types';
import type { NodeInput, NodePluginArgs } from 'gatsby';
interface ContentNode<Type extends string> extends NodeInput, Metadata {
ref: string;
internal: {
type: Type;
} & NodeInput['internal'];
}
interface Link {
object: string;
id: string;
}
type Entity = Database | Page;
type NormalizedEntity<E extends Entity = Entity> = E extends any
? Omit<E, 'parent'> & {
parent: Link | null;
children: Link[];
}
: never;
/** manage nodes based on data returned from Notion API */
export class NodeManager {
private createNode: NodePluginArgs['actions']['createNode'];
private deleteNode: NodePluginArgs['actions']['deleteNode'];
private touchNode: NodePluginArgs['actions']['touchNode'];
private createNodeId: NodePluginArgs['createNodeId'];
private createContentDigest: NodePluginArgs['createContentDigest'];
private cache: NodePluginArgs['cache'];
private getNode: NodePluginArgs['getNode'];
private reporter: NodePluginArgs['reporter'];
/**
* create a node manager using arguments from the sourceNodes API
* @param args arguments passed from the sourceNodes API
*/
constructor(args: NodePluginArgs) {
/* eslint-disable @typescript-eslint/unbound-method */
const {
actions: { createNode, deleteNode, touchNode },
cache,
createContentDigest,
createNodeId,
getNode,
reporter,
} = args;
/* eslint-enable */
this.cache = cache;
this.createNode = createNode;
this.deleteNode = deleteNode;
this.touchNode = touchNode;
this.createNodeId = createNodeId;
this.createContentDigest = createContentDigest;
this.getNode = getNode;
this.reporter = reporter;
}
/**
* update nodes available on gatsby
* @param entities all entities collected from notion, including database and page
*/
public async update(entities: Entity[]): Promise<void> {
// get entries with relationship build-in
const old = new Map<string, NodeInput>(
((await this.cache.get('nodeGraph')) as
| Array<[string, NodeInput]>
| undefined) ?? [],
);
const current = this.computeNodeGraph(entities);
const { added, updated, removed, unchanged } = computeChanges(old, current);
// for the usage of createNode
// see https://www.gatsbyjs.com/docs/reference/config-files/actions/#createNode
await this.addNodes(added);
await this.updateNodes(updated);
this.removeNodes(removed);
this.touchNodes(unchanged);
await this.cache.set('nodeGraph', [...current.entries()]);
}
/**
* add new nodes
* @param added new nodes to be added
*/
private async addNodes(added: NodeInput[]): Promise<void> {
for (const node of added) {
// DEBT: disable a false alarm from eslint as currently Gatsby is exporting an incorrect type
// this should be removed when https://github.com/gatsbyjs/gatsby/pull/32522 is merged
/* eslint-disable @typescript-eslint/await-thenable */
// create the node
await this.createNode(node);
/* eslint-enable */
}
// don't be noisy if there's nothing new happen
if (added.length > 0) {
this.reporter.info(`[${name}] added ${added.length} nodes`);
}
}
/**
* update existing nodes
* @param updated updated nodes
*/
private async updateNodes(updated: NodeInput[]): Promise<void> {
for (const node of updated) {
// DEBT: disable a false alarm from eslint as currently Gatsby is exporting an incorrect type
// this should be removed when https://github.com/gatsbyjs/gatsby/pull/32522 is merged
/* eslint-disable @typescript-eslint/await-thenable */
// update the node
await this.createNode(node);
/* eslint-enable */
}
// don't be noisy if there's nothing new happen
if (updated.length > 0) {
this.reporter.info(`[${name}] updated ${updated.length} nodes`);
}
}
/**
* remove old nodes
* @param removed nodes to be removed
*/
private removeNodes(removed: NodeInput[]): void {
for (const node of removed) {
this.deleteNode(node);
}
// don't be noisy if there's nothing new happen
if (removed.length > 0) {
this.reporter.info(`[${name}] removed ${removed.length} nodes`);
}
}
/**
* keep unchanged notion nodes alive
* @param untouched list of current notion entities
*/
private touchNodes(untouched: NodeInput[]): void {
for (const node of untouched) {
// DEBT: disable a false alarm from eslint as currently Gatsby is exporting an incorrect type
// this should be removed when https://github.com/gatsbyjs/gatsby/pull/32522 is merged
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (this.getNode(node.id)) {
// just make a light-touched operation if the node is still alive
this.touchNode({
id: node.id,
internal: {
type: node.internal.type,
contentDigest: node.internal.contentDigest,
},
});
} else {
// recreate it again if somehow it's missing
void this.createNode(node);
}
}
this.reporter.info(`[${name}] keeping ${untouched.length} nodes`);
}
/**
* convert entities into gatsby node with full parent-child relationship
* @param entities all sort of entities including database and page
* @returns a map of gatsby nodes with parent and children linked
*/
private computeNodeGraph(entities: Entity[]): Map<string, NodeInput> {
// first compute the graph with entities before converting to nodes
const entityMap = computeEntityMap(entities);
return new Map<string, NodeInput>(
[...entityMap.entries()].map(([id, entity]) => [
id,
this.nodifyEntity(entity),
]),
);
}
/**
* create a database node
* @param database a full database object
* @returns a database node
*/
private createDatabaseNode(
database: NormalizedEntity<Database>,
): ContentNode<'NotionDatabase'> {
return this.createBaseNode(database, { type: 'NotionDatabase' });
}
/**
* create a page node
* @param page a full page object
* @returns a page node
*/
private createPageNode(
page: NormalizedEntity<Page>,
): ContentNode<'NotionPage'> {
return this.createBaseNode(page, {
type: 'NotionPage',
content: page.markdown,
mediaType: 'text/markdown',
});
}
/**
* create a node based on common field from an entity
* @param entity a database or page
* @param internal extra fields to be merged with in the internal field
* @returns a node with common data
*/
private createBaseNode<T extends string>(
entity: NormalizedEntity,
internal: Omit<NodeInput['internal'], 'contentDigest'> & { type: T },
): ContentNode<T> {
const basis = {
id: this.createNodeId(`${entity.object}:${entity.id}`),
ref: entity.id,
title: entity.title,
...entity.metadata,
...(entity.object === 'page' ? { properties: entity.properties } : {}),
parent: entity.parent
? this.createNodeId(`${entity.parent.object}:${entity.parent.id}`)
: null,
children: entity.children.map(({ object, id }) =>
this.createNodeId(`${object}:${id}`),
),
};
const excludedKeys = ['parent', 'children', 'internal'];
const contentDigest = this.createContentDigest(omit(basis, excludedKeys));
return {
...basis,
internal: {
contentDigest,
...internal,
},
};
}
/**
* convert an entity to a NodeInput
* @param entity the entity to be converted
* @returns converted entity ready to be consumed by gatsby
*/
private nodifyEntity(entity: NormalizedEntity): NodeInput {
switch (entity.object) {
case 'database':
return this.createDatabaseNode(entity);
case 'page':
return this.createPageNode(entity);
/* istanbul ignore next */
default:
throw new TypeError(`unable to process ${JSON.stringify(entity)}`);
}
}
}
/**
* compute changes between two node graphs
* @param old the old graph
* @param current the latest graph
* @returns a map of nodes in different states
*/
export function computeChanges(
old: Map<string, NodeInput>,
current: Map<string, NodeInput>,
): Record<'added' | 'updated' | 'removed' | 'unchanged', NodeInput[]> {
const added = [...current.entries()].filter(([id]) => !old.has(id));
const removed = [...old.entries()].filter(([id]) => !current.has(id));
const bothExists = [...current.entries()].filter(([id]) => old.has(id));
const updated = bothExists.filter(
([id, node]) =>
old.get(id)!.internal.contentDigest !== node.internal.contentDigest,
);
const unchanged = bothExists.filter(
([id, node]) =>
old.get(id)!.internal.contentDigest === node.internal.contentDigest,
);
return {
added: added.map(([, node]) => node),
updated: updated.map(([, node]) => node),
removed: removed.map(([, node]) => node),
unchanged: unchanged.map(([, node]) => node),
};
}
/**
* attach parent-child relationship to gatsby node
* @param entities all sort of entities including database and page
* @returns a map of entities with parent and children linked
*/
export function computeEntityMap(
entities: Entity[],
): Map<string, NormalizedEntity> {
// create a new working set
const map = new Map<string, NormalizedEntity>();
for (const entity of entities) {
map.set(`${entity.object}:${entity.id}`, {
...entity,
parent: normalizeParent(entity.parent),
children: [],
});
}
for (const { id, parent, object } of entities) {
const child = { object, id };
switch (parent.type) {
case 'database_id':
map.get(`database:${parent.database_id}`)?.children.push(child);
break;
case 'page_id':
map.get(`page:${parent.page_id}`)?.children.push(child);
break;
case 'workspace':
// do nothing
break;
/* istanbul ignore next */
default:
throw new TypeError(`unknown parent type from ${object}:${id}`);
}
}
return map;
}
/**
* transform the parent field to an unified format
* @param parent the parent field returned from Notion API
* @returns information about the parent in an unified format
*/
export function normalizeParent(parent: Entity['parent']): Link | null {
switch (parent.type) {
case 'database_id':
return { object: 'database', id: parent.database_id };
case 'page_id':
return { object: 'page', id: parent.page_id };
case 'workspace':
return null;
/* istanbul ignore next */
default:
throw new TypeError(`unknown parent`);
}
}
/**
* return an object with the specified keys omitted
* @param record the record to be converted
* @param keys a list of keys to be omitted
* @returns an object with the specified keys omitted
*/
function omit(
record: Record<string, unknown>,
keys: string[],
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(record).filter(([key]) => !keys.includes(key)),
);
}