mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 07:17:00 +08:00
This is related to issue/feature request https://github.com/toeverything/AFFiNE/issues/13962. This PR extends the Notion import functionality to properly handle date fields from databases. Previously, these were imported as text (see photo below), which served little purpose. These Notion date fields are now parsed as actual dates, and imported to AFFiNE as epoch time (which is what the date field in AFFiNe expects). Because of this, even date fields with time (e.g. 09:00 AM) are also handled correctly - although they are only shown as dates, since AFFiNE's `Date` field does not support time. Tested with several Notion imports both with and without time, and they all seem to work correctly. Affected files: - blocksuite/affine/blocks/database/src/adapters/notion-html.ts Old: <img width="802" height="305" alt="image" src="https://github.com/user-attachments/assets/44019dba-cffb-4a30-a5ea-69cd9f86e0a1" /> New: <img width="804" height="271" alt="image" src="https://github.com/user-attachments/assets/3f52f328-7ee3-4754-9726-10dcfa0f8462" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced Notion imports with automatic date column detection. When importing Notion databases, date fields are now automatically recognized, properly configured as date columns, and formatted correctly. This improvement ensures accurate data preservation, eliminates manual type corrections, and provides a streamlined import experience for all users working with date-rich Notion databases. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
import { DatabaseBlockSchema } from '@blocksuite/affine-model';
|
|
import {
|
|
AdapterTextUtils,
|
|
BlockNotionHtmlAdapterExtension,
|
|
type BlockNotionHtmlAdapterMatcher,
|
|
HastUtils,
|
|
} from '@blocksuite/affine-shared/adapters';
|
|
import { getTagColor } from '@blocksuite/data-view';
|
|
import { type BlockSnapshot, nanoid } from '@blocksuite/store';
|
|
|
|
const ColumnClassMap: Record<string, string> = {
|
|
typesSelect: 'select',
|
|
typesMultipleSelect: 'multi-select',
|
|
typesNumber: 'number',
|
|
typesCheckbox: 'checkbox',
|
|
typesText: 'rich-text',
|
|
typesTitle: 'title',
|
|
typesDate: 'date',
|
|
};
|
|
|
|
const NotionDatabaseToken = '.collection-content';
|
|
const NotionDatabaseTitleToken = '.collection-title';
|
|
|
|
type BlocksuiteTableColumn = {
|
|
type: string;
|
|
name: string;
|
|
data: {
|
|
options?: {
|
|
id: string;
|
|
value: string;
|
|
color: string;
|
|
}[];
|
|
};
|
|
id: string;
|
|
};
|
|
|
|
type BlocksuiteTableRow = Record<
|
|
string,
|
|
{
|
|
columnId: string;
|
|
value: unknown;
|
|
}
|
|
>;
|
|
|
|
const DATABASE_NODE_TYPES = new Set(['table', 'th', 'tr']);
|
|
|
|
export const databaseBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
|
{
|
|
flavour: DatabaseBlockSchema.model.flavour,
|
|
toMatch: o =>
|
|
HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName),
|
|
fromMatch: () => false,
|
|
toBlockSnapshot: {
|
|
enter: (o, context) => {
|
|
if (!HastUtils.isElement(o.node)) {
|
|
return;
|
|
}
|
|
const { walkerContext, deltaConverter, pageMap } = context;
|
|
switch (o.node.tagName) {
|
|
case 'th': {
|
|
const columnId = nanoid();
|
|
const columnTypeClass = HastUtils.querySelector(o.node, 'svg')
|
|
?.properties?.className;
|
|
const columnType = Array.isArray(columnTypeClass)
|
|
? (ColumnClassMap[columnTypeClass[0] ?? ''] ?? 'rich-text')
|
|
: 'rich-text';
|
|
walkerContext.pushGlobalContextStack<BlocksuiteTableColumn>(
|
|
'hast:table:column',
|
|
{
|
|
type: columnType,
|
|
name: HastUtils.getTextContent(
|
|
HastUtils.getTextChildrenOnlyAst(o.node)
|
|
),
|
|
data: Object.create(null),
|
|
id: columnId,
|
|
}
|
|
);
|
|
// disable icon img in th
|
|
walkerContext.setGlobalContext('hast:disableimg', true);
|
|
break;
|
|
}
|
|
case 'tr': {
|
|
if (
|
|
o.parent?.node.type === 'element' &&
|
|
o.parent.node.tagName === 'tbody'
|
|
) {
|
|
const columns =
|
|
walkerContext.getGlobalContextStack<BlocksuiteTableColumn>(
|
|
'hast:table:column'
|
|
);
|
|
const row = Object.create(null);
|
|
let plainTable = false;
|
|
HastUtils.getElementChildren(o.node).forEach((child, index) => {
|
|
if (plainTable || columns[index] === undefined) {
|
|
plainTable = true;
|
|
if (columns[index] === undefined) {
|
|
columns.push({
|
|
type: 'rich-text',
|
|
name: '',
|
|
data: Object.create(null),
|
|
id: nanoid(),
|
|
});
|
|
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
|
'hast:table:children',
|
|
{
|
|
type: 'block',
|
|
id: nanoid(),
|
|
flavour: 'affine:paragraph',
|
|
props: {
|
|
text: {
|
|
'$blocksuite:internal:text$': true,
|
|
delta: deltaConverter.astToDelta(child),
|
|
},
|
|
type: 'text',
|
|
},
|
|
children: [],
|
|
}
|
|
);
|
|
}
|
|
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
|
'hast:table:children',
|
|
{
|
|
type: 'block',
|
|
id: nanoid(),
|
|
flavour: 'affine:paragraph',
|
|
props: {
|
|
text: {
|
|
'$blocksuite:internal:text$': true,
|
|
delta: deltaConverter.astToDelta(child),
|
|
},
|
|
type: 'text',
|
|
},
|
|
children: [],
|
|
}
|
|
);
|
|
const column = columns[index];
|
|
if (!column) {
|
|
return;
|
|
}
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: HastUtils.getTextContent(child),
|
|
};
|
|
} else if (HastUtils.querySelector(child, '.cell-title')) {
|
|
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
|
'hast:table:children',
|
|
{
|
|
type: 'block',
|
|
id: nanoid(),
|
|
flavour: 'affine:paragraph',
|
|
props: {
|
|
text: {
|
|
'$blocksuite:internal:text$': true,
|
|
delta: deltaConverter.astToDelta(child, { pageMap }),
|
|
},
|
|
type: 'text',
|
|
},
|
|
children: [],
|
|
}
|
|
);
|
|
columns[index].type = 'title';
|
|
return;
|
|
}
|
|
const optionIds: string[] = [];
|
|
const column = columns[index];
|
|
if (!column) {
|
|
return;
|
|
}
|
|
|
|
// Check for <time> element to find date field from Notion.
|
|
if (HastUtils.querySelector(child, 'time')) {
|
|
const timeElement = HastUtils.querySelector(child, 'time');
|
|
let rawColumnData =
|
|
HastUtils.getTextContent(timeElement).trim();
|
|
|
|
if (rawColumnData.startsWith('@')) {
|
|
rawColumnData = rawColumnData.slice(1);
|
|
}
|
|
|
|
const columnDate = new Date(rawColumnData);
|
|
const timestamp = columnDate.getTime();
|
|
|
|
if (!Number.isNaN(timestamp)) {
|
|
column.data = {};
|
|
if (column.type !== 'date') {
|
|
column.type = 'date';
|
|
}
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: timestamp,
|
|
};
|
|
} else {
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: HastUtils.getTextContent(child),
|
|
};
|
|
}
|
|
} else if (HastUtils.querySelector(child, '.selected-value')) {
|
|
if (!('options' in column.data)) {
|
|
column.data.options = [];
|
|
}
|
|
if (!['multi-select', 'select'].includes(column.type)) {
|
|
column.type = 'select';
|
|
}
|
|
if (
|
|
column.type === 'select' &&
|
|
child.type === 'element' &&
|
|
child.children.length > 1
|
|
) {
|
|
column.type = 'multi-select';
|
|
}
|
|
child.type === 'element' &&
|
|
child.children.forEach(span => {
|
|
const filteredArray = column.data.options?.filter(
|
|
option =>
|
|
option.value === HastUtils.getTextContent(span)
|
|
);
|
|
const id = filteredArray?.length
|
|
? (filteredArray[0]?.id ?? nanoid())
|
|
: nanoid();
|
|
if (!filteredArray?.length) {
|
|
column.data.options?.push({
|
|
id,
|
|
value: HastUtils.getTextContent(span),
|
|
color: getTagColor(),
|
|
});
|
|
}
|
|
optionIds.push(id);
|
|
});
|
|
// Expand will be done when leaving the table
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: optionIds,
|
|
};
|
|
} else if (HastUtils.querySelector(child, '.checkbox')) {
|
|
if (column.type !== 'checkbox') {
|
|
column.type = 'checkbox';
|
|
}
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: HastUtils.querySelector(child, '.checkbox-on')
|
|
? true
|
|
: false,
|
|
};
|
|
} else if (column.type === 'number') {
|
|
const text = HastUtils.getTextContent(child);
|
|
const number = Number(text);
|
|
if (Number.isNaN(number)) {
|
|
column.type = 'rich-text';
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: AdapterTextUtils.createText(text),
|
|
};
|
|
} else {
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: number,
|
|
};
|
|
}
|
|
} else {
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: HastUtils.getTextContent(child),
|
|
};
|
|
}
|
|
if (
|
|
column.type === 'rich-text' &&
|
|
!AdapterTextUtils.isText(row[column.id].value)
|
|
) {
|
|
row[column.id] = {
|
|
columnId: column.id,
|
|
value: AdapterTextUtils.createText(row[column.id].value),
|
|
};
|
|
}
|
|
});
|
|
walkerContext.setGlobalContextStack('hast:table:column', columns);
|
|
walkerContext.pushGlobalContextStack('hast:table:rows', row);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
leave: (o, context) => {
|
|
if (!HastUtils.isElement(o.node)) {
|
|
return;
|
|
}
|
|
const { walkerContext } = context;
|
|
switch (o.node.tagName) {
|
|
case 'table': {
|
|
const columns =
|
|
walkerContext.getGlobalContextStack<BlocksuiteTableColumn>(
|
|
'hast:table:column'
|
|
);
|
|
walkerContext.setGlobalContextStack('hast:table:column', []);
|
|
const children = walkerContext.getGlobalContextStack<BlockSnapshot>(
|
|
'hast:table:children'
|
|
);
|
|
walkerContext.setGlobalContextStack('hast:table:children', []);
|
|
const cells = Object.create(null);
|
|
walkerContext
|
|
.getGlobalContextStack<BlocksuiteTableRow>('hast:table:rows')
|
|
.forEach((row, i) => {
|
|
Object.keys(row).forEach(columnId => {
|
|
const cell = row[columnId];
|
|
if (!cell) {
|
|
return;
|
|
}
|
|
if (
|
|
columns.find(column => column.id === columnId)?.type ===
|
|
'select'
|
|
) {
|
|
cell.value = (cell.value as string[])[0];
|
|
}
|
|
});
|
|
cells[children.at(i)?.id ?? nanoid()] = row;
|
|
});
|
|
walkerContext.setGlobalContextStack('hast:table:cells', []);
|
|
let databaseTitle = '';
|
|
if (
|
|
o.parent?.node.type === 'element' &&
|
|
HastUtils.querySelector(o.parent.node, NotionDatabaseToken)
|
|
) {
|
|
databaseTitle = HastUtils.getTextContent(
|
|
HastUtils.querySelector(o.parent.node, NotionDatabaseTitleToken)
|
|
);
|
|
}
|
|
walkerContext.openNode(
|
|
{
|
|
type: 'block',
|
|
id: nanoid(),
|
|
flavour: DatabaseBlockSchema.model.flavour,
|
|
props: {
|
|
views: [
|
|
{
|
|
id: nanoid(),
|
|
name: 'Table View',
|
|
mode: 'table',
|
|
columns: [],
|
|
filter: {
|
|
type: 'group',
|
|
op: 'and',
|
|
conditions: [],
|
|
},
|
|
header: {
|
|
titleColumn:
|
|
columns.find(column => column.type === 'title')?.id ??
|
|
'',
|
|
iconColumn: 'type',
|
|
},
|
|
},
|
|
],
|
|
title: {
|
|
'$blocksuite:internal:text$': true,
|
|
delta: databaseTitle
|
|
? [
|
|
{
|
|
insert: databaseTitle,
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
columns,
|
|
cells,
|
|
},
|
|
children: [],
|
|
},
|
|
'children'
|
|
);
|
|
children.forEach(child => {
|
|
walkerContext.openNode(child, 'children').closeNode();
|
|
});
|
|
walkerContext.closeNode();
|
|
walkerContext.cleanGlobalContextStack('hast:table:column');
|
|
walkerContext.cleanGlobalContextStack('hast:table:rows');
|
|
walkerContext.cleanGlobalContextStack('hast:table:children');
|
|
break;
|
|
}
|
|
case 'th': {
|
|
walkerContext.setGlobalContext('hast:disableimg', false);
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
},
|
|
fromBlockSnapshot: {},
|
|
};
|
|
|
|
export const DatabaseBlockNotionHtmlAdapterExtension =
|
|
BlockNotionHtmlAdapterExtension(databaseBlockNotionHtmlAdapterMatcher);
|