Skip to content

Commit 3517989

Browse files
feat: add support for configurable views for child databases
1 parent 0ef9760 commit 3517989

10 files changed

+356
-202
lines changed

src/BlockRenderer.ts

Lines changed: 20 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,12 @@
11
import {
2-
Block as PublicBlock,
3-
BlockBase,
4-
Emoji,
5-
File,
6-
ExternalFile,
7-
ExternalFileWithCaption,
8-
FileWithCaption,
9-
ImageBlock,
10-
RichText,
11-
} from "@notionhq/client/build/src/api-types";
12-
13-
import * as markdownTable from "./markdown-table";
14-
import { AssetWriter } from "./AssetWriter";
15-
import { Database } from "./Database";
16-
import { DeferredRenderer } from "./DeferredRenderer";
17-
import { RichTextRenderer } from "./RichTextRenderer";
18-
import { logger } from "./logger";
19-
import { LinkRenderer } from "./LinkRenderer";
2+
Block as PublicBlock, BlockBase, Emoji, ExternalFile, ExternalFileWithCaption, File,
3+
FileWithCaption, ImageBlock, RichText
4+
} from '@notionhq/client/build/src/api-types';
5+
6+
import { AssetWriter } from './AssetWriter';
7+
import { DeferredRenderer } from './DeferredRenderer';
8+
import { logger } from './logger';
9+
import { RichTextRenderer } from './RichTextRenderer';
2010

2111
const debug = require("debug")("blocks");
2212

@@ -65,9 +55,8 @@ export type Block =
6555

6656
export class BlockRenderer {
6757
constructor(
68-
private readonly deferredRenderer: DeferredRenderer,
6958
private readonly richText: RichTextRenderer,
70-
private readonly linkRenderer: LinkRenderer
59+
private readonly deferredRenderer: DeferredRenderer,
7160
) {}
7261

7362
async renderBlock(block: Block, assets: AssetWriter): Promise<string> {
@@ -79,22 +68,26 @@ export class BlockRenderer {
7968
case "heading_2":
8069
return "## " + await this.richText.renderMarkdown(block.heading_2.text);
8170
case "heading_3":
82-
return "### " + await this.richText.renderMarkdown(block.heading_3.text);
71+
return "### " +
72+
await this.richText.renderMarkdown(block.heading_3.text);
8373
case "bulleted_list_item":
8474
return (
85-
"- " + await this.richText.renderMarkdown(block.bulleted_list_item.text)
75+
"- " +
76+
await this.richText.renderMarkdown(block.bulleted_list_item.text)
8677
);
8778
case "numbered_list_item":
8879
return (
89-
"1. " + await this.richText.renderMarkdown(block.numbered_list_item.text)
80+
"1. " +
81+
await this.richText.renderMarkdown(block.numbered_list_item.text)
9082
);
9183
case "to_do":
9284
return "[ ] " + await this.richText.renderMarkdown(block.to_do.text);
9385
case "image":
9486
return await this.renderImage(block, assets);
9587
case "quote":
9688
block as any;
97-
return "> " + await this.richText.renderMarkdown((block as any).quote.text);
89+
return "> " +
90+
await this.richText.renderMarkdown((block as any).quote.text);
9891
case "code":
9992
return (
10093
"```" +
@@ -113,17 +106,7 @@ export class BlockRenderer {
113106
case "divider":
114107
return "---";
115108
case "child_database":
116-
// queue all pages in the database for individual, deferred rendering
117-
const db = await this.deferredRenderer.renderDatabasePages(block.id);
118-
const msg = `<!-- included database ${block.id} -->\n`;
119-
120-
if (db.config.skipMarkdownTable) {
121-
return msg;
122-
}
123-
124-
// todo: make this nicer, e.g. render multi tables
125-
return msg + this.renderTables(db);
126-
109+
return await this.deferredRenderer.renderChildDatabase(block.id);
127110
case "toggle":
128111
case "child_page":
129112
case "embed":
@@ -136,35 +119,11 @@ export class BlockRenderer {
136119
default:
137120
return this.renderUnsupported(
138121
`unsupported block type: ${block.type}`,
139-
block
122+
block,
140123
);
141124
}
142125
}
143126

144-
renderTables(db: Database) {
145-
// todo: handle empty page
146-
const props = db.pages[0].properties;
147-
148-
const table: any[][] = [];
149-
150-
const headers = Array.from(props.keys.keys());
151-
table[0] = headers;
152-
153-
const cols = Array.from(props.keys.values());
154-
db.pages.forEach((r) =>
155-
table.push(
156-
cols.map((c, i) => {
157-
const content = escapeTableCell(r.properties.values[c]);
158-
return i == 0
159-
? this.linkRenderer.renderPageLink(content, r) // make the first cell a relative link to the page
160-
: content;
161-
})
162-
)
163-
);
164-
165-
return markdownTable.markdownTable(table);
166-
}
167-
168127
private renderIcon(icon: File | ExternalFile | Emoji): string {
169128
switch (icon.type) {
170129
case "emoji":
@@ -173,7 +132,7 @@ export class BlockRenderer {
173132
case "external":
174133
return this.renderUnsupported(
175134
`unsupported icon type: ${icon.type}`,
176-
icon
135+
icon,
177136
);
178137
}
179138
}
@@ -204,12 +163,3 @@ export class BlockRenderer {
204163
return `<!-- ${msg} -->`;
205164
}
206165
}
207-
208-
function escapeTableCell(content: string | number | any): string {
209-
// markdown table cells do not support newlines, however we can insert <br> elements instead
210-
if (typeof content === "string") {
211-
return content.replace(/\n/g, "<br>");
212-
}
213-
214-
return content.toString();
215-
}

src/ChildDatabaseRenderer.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Client } from '@notionhq/client/build/src';
2+
import { Page } from '@notionhq/client/build/src/api-types';
3+
4+
import { SyncConfig } from './';
5+
import { lookupDatabaseConfig } from './config';
6+
import { DatabaseViewRenderer } from './DatabaseViewRenderer';
7+
import { DeferredRenderer } from './DeferredRenderer';
8+
import { DatabaseConfig } from './SyncConfig';
9+
import { TableRenderer } from './TableRenderer';
10+
11+
export class ChildDatabaseRenderer {
12+
constructor(
13+
private readonly config: SyncConfig,
14+
private readonly publicApi: Client,
15+
private readonly deferredRenderer: DeferredRenderer,
16+
private readonly tableRenderer: TableRenderer,
17+
private readonly viewRenderer: DatabaseViewRenderer,
18+
) {}
19+
20+
async renderChildDatabase(databaseId: string): Promise<string> {
21+
const dbConfig = lookupDatabaseConfig(this.config, databaseId);
22+
23+
const msg = `<!-- included database ${databaseId} -->\n`;
24+
25+
// no view was defined for this database, render as a plain inline table
26+
const allPages = await this.fetchPages(databaseId, dbConfig);
27+
28+
const isCmsDb = this.config.cmsDatabaseId !== databaseId;
29+
if (isCmsDb && !dbConfig.views) {
30+
return msg + await this.tableRenderer.renderTable(allPages, dbConfig);
31+
}
32+
33+
// queue all pages in the database for individual, deferred rendering
34+
const prepareRenderPageTasks = allPages.map((x) =>
35+
this.deferredRenderer.renderPage(x, dbConfig)
36+
);
37+
38+
// note: the await here is not actually starting to render the pages, however it prepares the page render task
39+
const renderPageTasks = await Promise.all(prepareRenderPageTasks);
40+
41+
const db = {
42+
pages: renderPageTasks,
43+
config: dbConfig,
44+
};
45+
46+
return this.viewRenderer.renderViews(db);
47+
}
48+
49+
private async fetchPages(
50+
databaseId: string,
51+
dbConfig: DatabaseConfig,
52+
): Promise<Page[]> {
53+
const db = await this.publicApi.databases.retrieve({
54+
database_id: databaseId,
55+
});
56+
57+
const allPages = await this.publicApi.databases.query({
58+
database_id: db.id,
59+
sorts: dbConfig.sorts,
60+
page_size: 100,
61+
}); // todo: paging
62+
63+
if (allPages.next_cursor) {
64+
throw new Error(
65+
`Paging not implemented, db ${db.id} has more than 100 entries`,
66+
);
67+
}
68+
69+
return allPages.results;
70+
}
71+
}

src/DatabaseRenderer.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.

src/DatabaseViewRenderer.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Database } from './Database';
2+
import { TableRenderer } from './TableRenderer';
3+
4+
const debug = require("debug")("database");
5+
6+
export class DatabaseViewRenderer {
7+
constructor(
8+
private readonly tableRenderer: TableRenderer,
9+
) {}
10+
11+
renderViews(db: Database): string {
12+
const views = db.config.views?.map((view) => {
13+
const propKeys = db.pages[0].properties.keys;
14+
const propKey = propKeys.get(view.properties.groupBy);
15+
16+
if (!propKey) {
17+
const msg =
18+
`Could not render view ${view.title}, groupBy property ${view.properties.groupBy} not found`;
19+
debug(msg + "%O", view);
20+
throw new Error(msg);
21+
}
22+
23+
const grouped = new Array(
24+
...groupBy(db.pages, (p) => p.properties.values[propKey]),
25+
);
26+
27+
return grouped
28+
.map(([key, pages]) => this.tableRenderer.renderView(pages, key, view))
29+
.join("\n\n");
30+
});
31+
32+
return views?.join("\n\n") || "";
33+
}
34+
}
35+
36+
/**
37+
* @description
38+
* Takes an Array<V>, and a grouping function,
39+
* and returns a Map of the array grouped by the grouping function.
40+
*
41+
* @param list An array of type V.
42+
* @param keyGetter A Function that takes the the Array type V as an input, and returns a value of type K.
43+
* K is generally intended to be a property key of V.
44+
*
45+
* @returns Map of the array grouped by the grouping function.
46+
*/
47+
export function groupBy<K, V>(
48+
list: Array<V>,
49+
keyGetter: (input: V) => K,
50+
): Map<K, Array<V>> {
51+
const map = new Map<K, Array<V>>();
52+
list.forEach((item) => {
53+
const key = keyGetter(item);
54+
const collection = map.get(key);
55+
if (!collection) {
56+
map.set(key, [item]);
57+
} else {
58+
collection.push(item);
59+
}
60+
});
61+
return map;
62+
}

src/DeferredRenderer.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,33 @@
1-
import { Page } from "@notionhq/client/build/src/api-types";
2-
import { DatabaseConfig } from "./SyncConfig";
3-
import { Database } from "./Database";
4-
import { DatabaseRenderer } from "./DatabaseRenderer";
5-
import { logger } from "./logger";
6-
import { PageRenderer } from "./PageRenderer";
7-
import { RenderPageTask as RenderPageTask } from "./RenderPageTask";
8-
import { RenderedPage } from "./RenderedPage";
1+
import { Page } from '@notionhq/client/build/src/api-types';
2+
3+
import { ChildDatabaseRenderer } from './ChildDatabaseRenderer';
4+
import { logger } from './logger';
5+
import { PageRenderer } from './PageRenderer';
6+
import { RenderedPage } from './RenderedPage';
7+
import { RenderPageTask as RenderPageTask } from './RenderPageTask';
8+
import { DatabaseConfig } from './SyncConfig';
99

1010
const debug = require("debug")("rendering");
1111

1212
export class DeferredRenderer {
13-
private dbRenderer!: DatabaseRenderer;
13+
private dbRenderer!: ChildDatabaseRenderer;
1414
private pageRenderer!: PageRenderer;
1515

1616
private pageQueue: (() => Promise<any>)[] = [];
1717

18-
private readonly renderedDatabases = new Map<string, Database>();
1918
private readonly renderedPages = new Map<string, RenderPageTask>();
2019

2120
public initialize(
22-
dbRenderer: DatabaseRenderer,
21+
dbRenderer: ChildDatabaseRenderer,
2322
pageRenderer: PageRenderer
2423
) {
2524
this.dbRenderer = dbRenderer;
2625
this.pageRenderer = pageRenderer;
2726
}
2827

29-
public async renderDatabasePages(databaseId: string): Promise<Database> {
30-
const cached = this.renderedDatabases.get(databaseId);
31-
if (cached) {
32-
debug("db cache hit " + databaseId);
33-
return cached;
34-
}
35-
28+
public async renderChildDatabase(databaseId: string): Promise<string> {
3629
// database pages objects are retrieved immediately, but page bodys are queued for deferred rendering
37-
const fetched = await this.dbRenderer.renderDatabase(databaseId);
38-
this.renderedDatabases.set(databaseId, fetched);
30+
const fetched = await this.dbRenderer.renderChildDatabase(databaseId);
3931

4032
return fetched;
4133
}

0 commit comments

Comments
 (0)