Skip to content

Commit 38e7e49

Browse files
committed
Merge branch 'main' into feature/Migrate-Custom-Assistants-to-Agents
2 parents 07cf359 + 4c0223a commit 38e7e49

File tree

6 files changed

+423
-20
lines changed

6 files changed

+423
-20
lines changed

packages/components/nodes/documentloaders/Notion/NotionDB.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ICommonObject, IDocument, INode, INodeData, INodeParams } from '../../.
33
import { TextSplitter } from '@langchain/textsplitters'
44
import { NotionAPILoader, NotionAPILoaderOptions } from '@langchain/community/document_loaders/web/notionapi'
55
import { getCredentialData, getCredentialParam, handleEscapeCharacters, INodeOutputsValue } from '../../../src'
6+
import { applyCompactTableTransformer } from './notionTableFix'
67

78
class NotionDB_DocumentLoaders implements INode {
89
label: string
@@ -108,6 +109,7 @@ class NotionDB_DocumentLoaders implements INode {
108109
type: 'database'
109110
}
110111
const loader = new NotionAPILoader(obj)
112+
applyCompactTableTransformer(loader)
111113

112114
let docs: IDocument[] = []
113115
if (textSplitter) {

packages/components/nodes/documentloaders/Notion/NotionPage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ICommonObject, IDocument, INode, INodeData, INodeParams } from '../../.
33
import { TextSplitter } from '@langchain/textsplitters'
44
import { NotionAPILoader, NotionAPILoaderOptions } from '@langchain/community/document_loaders/web/notionapi'
55
import { getCredentialData, getCredentialParam, handleEscapeCharacters, INodeOutputsValue } from '../../../src'
6+
import { applyCompactTableTransformer } from './notionTableFix'
67

78
class NotionPage_DocumentLoaders implements INode {
89
label: string
@@ -105,6 +106,7 @@ class NotionPage_DocumentLoaders implements INode {
105106
type: 'page'
106107
}
107108
const loader = new NotionAPILoader(obj)
109+
applyCompactTableTransformer(loader)
108110

109111
let docs: IDocument[] = []
110112
if (textSplitter) {
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { applyCompactTableTransformer } from './notionTableFix'
2+
3+
/**
4+
* Creates a mock NotionAPILoader with fake n2mClient and notionClient.
5+
* The n2mClient.blockToMarkdown mock converts rich_text cells to their plain_text,
6+
* mimicking the real notion-to-md behavior (which appends trailing newlines).
7+
*/
8+
function createMockLoader(tableRows: MockTableRow[]) {
9+
let capturedTransformer: ((block: Record<string, unknown>) => Promise<string>) | null = null
10+
11+
const mockNotionClient = {
12+
blocks: {
13+
children: {
14+
list: jest.fn().mockResolvedValue({
15+
results: tableRows.map((row) => ({
16+
type: 'table_row',
17+
table_row: { cells: row.cells }
18+
})),
19+
has_more: false,
20+
next_cursor: null
21+
})
22+
}
23+
}
24+
}
25+
26+
const mockN2m = {
27+
setCustomTransformer: jest.fn((type: string, transformer: typeof capturedTransformer) => {
28+
if (type === 'table') {
29+
capturedTransformer = transformer
30+
}
31+
}),
32+
blockToMarkdown: jest.fn(async (block: { paragraph: { rich_text: MockRichText[] } }) => {
33+
// Simulate notion-to-md behavior: join plain_text with trailing newlines
34+
const text = block.paragraph.rich_text.map((rt: MockRichText) => rt.plain_text).join('')
35+
return text + '\n\n'
36+
})
37+
}
38+
39+
const loader = {
40+
n2mClient: mockN2m,
41+
notionClient: mockNotionClient
42+
}
43+
44+
return {
45+
loader,
46+
mockNotionClient,
47+
mockN2m,
48+
getTransformer: () => capturedTransformer
49+
}
50+
}
51+
52+
interface MockRichText {
53+
type: string
54+
plain_text: string
55+
}
56+
57+
interface MockTableRow {
58+
cells: MockRichText[][]
59+
}
60+
61+
function richText(text: string): MockRichText[] {
62+
return [{ type: 'text', plain_text: text }]
63+
}
64+
65+
function createTableBlock(options: { has_children: boolean; has_column_header: boolean }) {
66+
return {
67+
id: 'block-id',
68+
type: 'table',
69+
has_children: options.has_children,
70+
table: {
71+
has_column_header: options.has_column_header,
72+
has_row_header: false,
73+
table_width: 3
74+
}
75+
}
76+
}
77+
78+
describe('applyCompactTableTransformer', () => {
79+
it('registers a custom transformer for the table block type', () => {
80+
const { loader, mockN2m } = createMockLoader([])
81+
applyCompactTableTransformer(loader as never)
82+
expect(mockN2m.setCustomTransformer).toHaveBeenCalledWith('table', expect.any(Function))
83+
})
84+
85+
it('returns empty string when block has no children', async () => {
86+
const { loader, getTransformer } = createMockLoader([])
87+
applyCompactTableTransformer(loader as never)
88+
89+
const transformer = getTransformer()!
90+
const result = await transformer(createTableBlock({ has_children: false, has_column_header: true }))
91+
expect(result).toBe('')
92+
})
93+
94+
it('returns empty string when API returns no rows', async () => {
95+
const { loader, getTransformer } = createMockLoader([])
96+
applyCompactTableTransformer(loader as never)
97+
98+
const transformer = getTransformer()!
99+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
100+
expect(result).toBe('')
101+
})
102+
103+
it('produces a compact markdown table with column header', async () => {
104+
const rows: MockTableRow[] = [
105+
{ cells: [richText('Item'), richText('Price'), richText('Stock')] },
106+
{ cells: [richText('Apple'), richText('$1.00'), richText('50')] },
107+
{ cells: [richText('Banana'), richText('$0.50'), richText('100')] }
108+
]
109+
const { loader, getTransformer } = createMockLoader(rows)
110+
applyCompactTableTransformer(loader as never)
111+
112+
const transformer = getTransformer()!
113+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
114+
115+
expect(result).toBe(
116+
'| Item | Price | Stock |\n' + '| --- | --- | --- |\n' + '| Apple | $1.00 | 50 |\n' + '| Banana | $0.50 | 100 |'
117+
)
118+
})
119+
120+
it('generates blank header row when has_column_header is false', async () => {
121+
const rows: MockTableRow[] = [{ cells: [richText('Apple'), richText('$1.00')] }, { cells: [richText('Banana'), richText('$0.50')] }]
122+
const { loader, getTransformer } = createMockLoader(rows)
123+
applyCompactTableTransformer(loader as never)
124+
125+
const transformer = getTransformer()!
126+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: false }))
127+
128+
expect(result).toBe('| | |\n' + '| --- | --- |\n' + '| Apple | $1.00 |\n' + '| Banana | $0.50 |')
129+
})
130+
131+
it('trims trailing newlines from blockToMarkdown output', async () => {
132+
const rows: MockTableRow[] = [{ cells: [richText('Header')] }, { cells: [richText('Value')] }]
133+
const { loader, getTransformer, mockN2m } = createMockLoader(rows)
134+
135+
// Override to add extra newlines as notion-to-md does
136+
mockN2m.blockToMarkdown.mockImplementation(async (block: { paragraph: { rich_text: MockRichText[] } }) => {
137+
const text = block.paragraph.rich_text.map((rt: MockRichText) => rt.plain_text).join('')
138+
return text + '\n\n\n'
139+
})
140+
141+
applyCompactTableTransformer(loader as never)
142+
143+
const transformer = getTransformer()!
144+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
145+
146+
expect(result).toBe('| Header |\n| --- |\n| Value |')
147+
})
148+
149+
it('escapes pipe characters in cell content', async () => {
150+
const rows: MockTableRow[] = [{ cells: [richText('Header')] }, { cells: [richText('a|b|c')] }]
151+
const { loader, getTransformer } = createMockLoader(rows)
152+
applyCompactTableTransformer(loader as never)
153+
154+
const transformer = getTransformer()!
155+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
156+
157+
expect(result).toBe('| Header |\n| --- |\n| a\\|b\\|c |')
158+
})
159+
160+
it('escapes backslash characters in cell content', async () => {
161+
const rows: MockTableRow[] = [{ cells: [richText('Path')] }, { cells: [richText('C:\\Users\\file')] }]
162+
const { loader, getTransformer } = createMockLoader(rows)
163+
applyCompactTableTransformer(loader as never)
164+
165+
const transformer = getTransformer()!
166+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
167+
168+
expect(result).toBe('| Path |\n| --- |\n| C:\\\\Users\\\\file |')
169+
})
170+
171+
it('replaces internal newlines with spaces', async () => {
172+
const rows: MockTableRow[] = [{ cells: [richText('Header')] }, { cells: [richText('line1')] }]
173+
const { loader, getTransformer, mockN2m } = createMockLoader(rows)
174+
175+
mockN2m.blockToMarkdown.mockImplementation(async (block: { paragraph: { rich_text: MockRichText[] } }) => {
176+
const text = block.paragraph.rich_text.map((rt: MockRichText) => rt.plain_text).join('')
177+
// Simulate content with internal newlines
178+
return text === 'line1' ? 'line1\nline2\n\n' : text + '\n\n'
179+
})
180+
181+
applyCompactTableTransformer(loader as never)
182+
183+
const transformer = getTransformer()!
184+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
185+
186+
expect(result).toBe('| Header |\n| --- |\n| line1 line2 |')
187+
})
188+
189+
it('handles tables with long URLs without adding padding', async () => {
190+
const rows: MockTableRow[] = [
191+
{ cells: [richText('Item'), richText('URL')] },
192+
{ cells: [richText('Chatflows'), richText('https://flowiseai.com')] },
193+
{ cells: [richText('Docs'), richText('https://github.com/FlowiseAI/Flowise')] }
194+
]
195+
const { loader, getTransformer } = createMockLoader(rows)
196+
applyCompactTableTransformer(loader as never)
197+
198+
const transformer = getTransformer()!
199+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
200+
201+
// Verify no extra spaces — each cell should be trimmed tightly
202+
const lines = result.split('\n')
203+
expect(lines[0]).toBe('| Item | URL |')
204+
expect(lines[1]).toBe('| --- | --- |')
205+
expect(lines[2]).toBe('| Chatflows | https://flowiseai.com |')
206+
expect(lines[3]).toBe('| Docs | https://github.com/FlowiseAI/Flowise |')
207+
})
208+
209+
it('falls back to default handler when an error occurs', async () => {
210+
const { loader, getTransformer, mockNotionClient } = createMockLoader([])
211+
212+
// Simulate an API error
213+
mockNotionClient.blocks.children.list.mockRejectedValueOnce(new Error('API rate limited'))
214+
215+
applyCompactTableTransformer(loader as never)
216+
217+
const transformer = getTransformer()!
218+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
219+
220+
// Returning false tells notion-to-md to use the default table handler
221+
expect(result).toBe(false)
222+
})
223+
224+
it('handles pagination when fetching child blocks', async () => {
225+
const page1Rows: MockTableRow[] = [{ cells: [richText('Header')] }]
226+
const page2Rows: MockTableRow[] = [{ cells: [richText('Row1')] }]
227+
228+
const { loader, getTransformer, mockNotionClient } = createMockLoader([])
229+
230+
// Override to simulate paginated responses
231+
mockNotionClient.blocks.children.list
232+
.mockResolvedValueOnce({
233+
results: [{ type: 'table_row', table_row: { cells: page1Rows[0].cells } }],
234+
has_more: true,
235+
next_cursor: 'cursor-abc'
236+
})
237+
.mockResolvedValueOnce({
238+
results: [{ type: 'table_row', table_row: { cells: page2Rows[0].cells } }],
239+
has_more: false,
240+
next_cursor: null
241+
})
242+
243+
applyCompactTableTransformer(loader as never)
244+
245+
const transformer = getTransformer()!
246+
const result = await transformer(createTableBlock({ has_children: true, has_column_header: true }))
247+
248+
expect(mockNotionClient.blocks.children.list).toHaveBeenCalledTimes(2)
249+
expect(result).toBe('| Header |\n| --- |\n| Row1 |')
250+
})
251+
})

0 commit comments

Comments
 (0)