@sillsdev/docu-notion 0.17.0-alpha.2 → 0.17.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/NotionPage.d.ts +1 -1
- package/dist/NotionPage.js +6 -1
- package/dist/NotionPage.spec.js +62 -0
- package/dist/latex.spec.js +2 -0
- package/dist/plugins/ColumnListTransformer.js +12 -7
- package/dist/plugins/ColumnTransformer.d.ts +6 -0
- package/dist/plugins/ColumnTransformer.js +63 -34
- package/dist/plugins/ColumnTransformer.spec.js +84 -0
- package/dist/plugins/EscapeHtmlBlockModifier.spec.js +6 -1
- package/dist/plugins/internalLinks.js +36 -10
- package/dist/plugins/internalLinks.spec.js +46 -0
- package/dist/pull.js +53 -26
- package/dist/transform.js +4 -12
- package/dist/types.d.ts +1 -1
- package/package.json +3 -5
package/dist/NotionPage.d.ts
CHANGED
package/dist/NotionPage.js
CHANGED
|
@@ -20,6 +20,11 @@ var PageType;
|
|
|
20
20
|
PageType[PageType["DatabasePage"] = 0] = "DatabasePage";
|
|
21
21
|
PageType[PageType["Simple"] = 1] = "Simple";
|
|
22
22
|
})(PageType || (exports.PageType = PageType = {}));
|
|
23
|
+
function isDatabaseBackedPage(metadata) {
|
|
24
|
+
var _a;
|
|
25
|
+
const parentType = (_a = metadata.parent) === null || _a === void 0 ? void 0 : _a.type;
|
|
26
|
+
return parentType === "database_id" || parentType === "data_source_id";
|
|
27
|
+
}
|
|
23
28
|
class NotionPage {
|
|
24
29
|
constructor(args) {
|
|
25
30
|
this.layoutContext = args.layoutContext;
|
|
@@ -53,7 +58,7 @@ class NotionPage {
|
|
|
53
58
|
...
|
|
54
59
|
},
|
|
55
60
|
*/
|
|
56
|
-
return this.metadata
|
|
61
|
+
return isDatabaseBackedPage(this.metadata)
|
|
57
62
|
? PageType.DatabasePage
|
|
58
63
|
: PageType.Simple;
|
|
59
64
|
}
|
package/dist/NotionPage.spec.js
CHANGED
|
@@ -140,4 +140,66 @@ describe("NotionPage", () => {
|
|
|
140
140
|
expect(result).toBe("Default Value");
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
|
+
describe("page type detection", () => {
|
|
144
|
+
it("treats data_source_id pages as database pages", () => {
|
|
145
|
+
const page = new NotionPage_1.NotionPage({
|
|
146
|
+
layoutContext: "Test Context",
|
|
147
|
+
pageId: "123",
|
|
148
|
+
order: 1,
|
|
149
|
+
metadata: Object.assign(Object.assign({}, mockMetadata), { parent: {
|
|
150
|
+
type: "data_source_id",
|
|
151
|
+
data_source_id: "source-123",
|
|
152
|
+
database_id: "database-123",
|
|
153
|
+
}, properties: {
|
|
154
|
+
Name: {
|
|
155
|
+
id: "title",
|
|
156
|
+
type: "title",
|
|
157
|
+
title: [
|
|
158
|
+
{
|
|
159
|
+
type: "text",
|
|
160
|
+
text: {
|
|
161
|
+
content: "Columns",
|
|
162
|
+
link: null,
|
|
163
|
+
},
|
|
164
|
+
annotations: {
|
|
165
|
+
bold: false,
|
|
166
|
+
italic: false,
|
|
167
|
+
strikethrough: false,
|
|
168
|
+
underline: false,
|
|
169
|
+
code: false,
|
|
170
|
+
color: "default",
|
|
171
|
+
},
|
|
172
|
+
plain_text: "Columns",
|
|
173
|
+
href: null,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
Status: {
|
|
178
|
+
id: "status",
|
|
179
|
+
type: "select",
|
|
180
|
+
select: {
|
|
181
|
+
id: "publish",
|
|
182
|
+
name: "Publish",
|
|
183
|
+
color: "green",
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
} }),
|
|
187
|
+
foundDirectlyInOutline: false,
|
|
188
|
+
});
|
|
189
|
+
expect(page.type).toBe(NotionPage_1.PageType.DatabasePage);
|
|
190
|
+
expect(page.nameOrTitle).toBe("Columns");
|
|
191
|
+
expect(page.status).toBe("Publish");
|
|
192
|
+
});
|
|
193
|
+
it("keeps workspace pages as simple pages", () => {
|
|
194
|
+
const page = new NotionPage_1.NotionPage({
|
|
195
|
+
layoutContext: "Test Context",
|
|
196
|
+
pageId: "123",
|
|
197
|
+
order: 1,
|
|
198
|
+
metadata: mockMetadata,
|
|
199
|
+
foundDirectlyInOutline: true,
|
|
200
|
+
});
|
|
201
|
+
expect(page.type).toBe(NotionPage_1.PageType.Simple);
|
|
202
|
+
expect(page.nameOrTitle).toBe("FooBar");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
143
205
|
});
|
package/dist/latex.spec.js
CHANGED
|
@@ -75,6 +75,7 @@ test("Latex Rendering", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
|
75
75
|
},
|
|
76
76
|
has_children: false,
|
|
77
77
|
archived: false,
|
|
78
|
+
in_trash: false,
|
|
78
79
|
type: "paragraph",
|
|
79
80
|
paragraph: {
|
|
80
81
|
rich_text: [
|
|
@@ -93,6 +94,7 @@ test("Latex Rendering", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
|
93
94
|
href: null,
|
|
94
95
|
},
|
|
95
96
|
],
|
|
97
|
+
icon: null,
|
|
96
98
|
color: "default",
|
|
97
99
|
},
|
|
98
100
|
},
|
|
@@ -10,16 +10,21 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.standardColumnListTransformer = void 0;
|
|
13
|
+
const ColumnTransformer_1 = require("./ColumnTransformer");
|
|
13
14
|
function notionColumnListToMarkdown(notionToMarkdown, getBlockChildren, block) {
|
|
14
15
|
return __awaiter(this, void 0, void 0, function* () {
|
|
15
|
-
|
|
16
|
-
// However https://github1s.com/NotionX/react-notion-x/blob/master/packages/react-notion-x/src/block.tsx#L528 can get it.
|
|
17
|
-
const { id, has_children } = block; // "any" because the notion api type system is complex with a union that don't know how to help TS to cope with
|
|
18
|
-
if (!has_children)
|
|
16
|
+
if (!(0, ColumnTransformer_1.isColumnListBlock)(block) || !block.has_children)
|
|
19
17
|
return "";
|
|
20
|
-
const column_list_children = yield getBlockChildren(id);
|
|
21
|
-
|
|
22
|
-
const
|
|
18
|
+
const column_list_children = yield getBlockChildren(block.id);
|
|
19
|
+
(0, ColumnTransformer_1.rememberColumnListChildren)(column_list_children);
|
|
20
|
+
const columnsToRender = column_list_children.filter((child) => child.type === "column");
|
|
21
|
+
const columns = [];
|
|
22
|
+
for (const column of columnsToRender) {
|
|
23
|
+
// Keep column rendering sequential. A column block can trigger more Notion
|
|
24
|
+
// reads downstream, so Promise.all() here would turn one page into a burst
|
|
25
|
+
// of concurrent API requests during stage 2.
|
|
26
|
+
columns.push(yield notionToMarkdown.blockToMarkdown(column));
|
|
27
|
+
}
|
|
23
28
|
return `<div class='notion-row'>\n${columns.join("\n\n")}\n</div>`;
|
|
24
29
|
});
|
|
25
30
|
}
|
|
@@ -1,2 +1,8 @@
|
|
|
1
|
+
import { ColumnListBlockObjectResponse } from "@notionhq/client";
|
|
2
|
+
import { ListBlockChildrenResponseResult } from "notion-to-md/build/types";
|
|
1
3
|
import { IPlugin } from "./pluginTypes";
|
|
4
|
+
import { NotionBlock } from "../types";
|
|
5
|
+
export declare function isColumnListBlock(block: NotionBlock | ListBlockChildrenResponseResult): block is ColumnListBlockObjectResponse;
|
|
2
6
|
export declare const standardColumnTransformer: IPlugin;
|
|
7
|
+
export declare function rememberColumnListChildren(columnBlocks: NotionBlock[]): void;
|
|
8
|
+
export declare function getColumnWidth(block: ListBlockChildrenResponseResult): string;
|
|
@@ -10,8 +10,31 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.standardColumnTransformer = void 0;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
exports.isColumnListBlock = isColumnListBlock;
|
|
14
|
+
exports.rememberColumnListChildren = rememberColumnListChildren;
|
|
15
|
+
exports.getColumnWidth = getColumnWidth;
|
|
16
|
+
const columnCountById = new Map();
|
|
17
|
+
const normalizedColumnRatioById = new Map();
|
|
18
|
+
function isColumnBlock(block) {
|
|
19
|
+
return "type" in block && block.type === "column";
|
|
20
|
+
}
|
|
21
|
+
function isColumnListBlock(block) {
|
|
22
|
+
return "type" in block && block.type === "column_list";
|
|
23
|
+
}
|
|
24
|
+
function getRawColumnRatio(block) {
|
|
25
|
+
if (!isColumnBlock(block))
|
|
26
|
+
return undefined;
|
|
27
|
+
const ratio = block.column.width_ratio;
|
|
28
|
+
return typeof ratio === "number" && Number.isFinite(ratio)
|
|
29
|
+
? ratio
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
32
|
+
function approximatelyEqual(left, right, epsilon = 0.000001) {
|
|
33
|
+
return Math.abs(left - right) < epsilon;
|
|
34
|
+
}
|
|
35
|
+
function isDefinedNumber(value) {
|
|
36
|
+
return value !== undefined;
|
|
37
|
+
}
|
|
15
38
|
exports.standardColumnTransformer = {
|
|
16
39
|
name: "standardColumnTransformer",
|
|
17
40
|
notionToMarkdownTransforms: [
|
|
@@ -21,48 +44,54 @@ exports.standardColumnTransformer = {
|
|
|
21
44
|
},
|
|
22
45
|
],
|
|
23
46
|
};
|
|
47
|
+
function rememberColumnListChildren(columnBlocks) {
|
|
48
|
+
const columns = columnBlocks.filter(isColumnBlock);
|
|
49
|
+
const columnCount = columns.length;
|
|
50
|
+
const explicitRatios = columns.map(getRawColumnRatio);
|
|
51
|
+
const allExplicit = explicitRatios.every(isDefinedNumber);
|
|
52
|
+
const explicitSum = explicitRatios.reduce((sum, ratio) => sum + (ratio !== null && ratio !== void 0 ? ratio : 0), 0);
|
|
53
|
+
const normalizedRatios = allExplicit && approximatelyEqual(explicitSum, 1)
|
|
54
|
+
? explicitRatios
|
|
55
|
+
: (() => {
|
|
56
|
+
const weights = explicitRatios.map(ratio => ratio !== null && ratio !== void 0 ? ratio : 1);
|
|
57
|
+
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
58
|
+
return totalWeight > 0
|
|
59
|
+
? weights.map(weight => weight / totalWeight)
|
|
60
|
+
: weights.map(() => 1 / Math.max(columnCount, 1));
|
|
61
|
+
})();
|
|
62
|
+
for (const [index, columnBlock] of columns.entries()) {
|
|
63
|
+
columnCountById.set(columnBlock.id, columnCount);
|
|
64
|
+
normalizedColumnRatioById.set(columnBlock.id, normalizedRatios[index]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
24
67
|
// This runs when notion-to-md encounters a column block
|
|
25
68
|
function notionColumnToMarkdown(notionToMarkdown, getBlockChildren, block) {
|
|
26
69
|
return __awaiter(this, void 0, void 0, function* () {
|
|
27
|
-
|
|
28
|
-
const { id, has_children } = block; // "any" because the notion api type system is complex with a union that don't know how to help TS to cope with
|
|
29
|
-
if (!has_children)
|
|
70
|
+
if (!isColumnBlock(block) || !block.has_children)
|
|
30
71
|
return "";
|
|
31
|
-
const columnChildren = yield getBlockChildren(id);
|
|
32
|
-
const childrenMdBlocksArray =
|
|
72
|
+
const columnChildren = yield getBlockChildren(block.id);
|
|
73
|
+
const childrenMdBlocksArray = [];
|
|
74
|
+
for (const child of columnChildren) {
|
|
75
|
+
// Intentionally serialize these subtree conversions. notion-to-md will fetch
|
|
76
|
+
// nested block children during blocksToMarkdown(), and parallelizing sibling
|
|
77
|
+
// columns creates bursts that can exceed Notion's per-integration rate limit.
|
|
78
|
+
childrenMdBlocksArray.push(yield notionToMarkdown.blocksToMarkdown([child]));
|
|
79
|
+
}
|
|
33
80
|
const childrenMarkdown = childrenMdBlocksArray.map(mdBlockArray => notionToMarkdown.toMarkdownString(mdBlockArray).parent);
|
|
34
|
-
const columnWidth =
|
|
81
|
+
const columnWidth = getColumnWidth(block);
|
|
35
82
|
return (`<div class='notion-column' style={{width: '${columnWidth}'}}>\n\n${childrenMarkdown.join("\n")}\n</div>` +
|
|
36
83
|
// Spacer between columns. CSS takes care of hiding this for the last column
|
|
37
84
|
// and when the screen is too narrow for multiple columns.
|
|
38
85
|
`<div className='notion-spacer'></div>`);
|
|
39
86
|
});
|
|
40
87
|
}
|
|
41
|
-
// The official API doesn't give us access to the format information, including column_ratio.
|
|
42
|
-
// So we use 'notion-client' which uses the unofficial API.
|
|
43
|
-
// Once the official API gives us access to the format information, we can remove this
|
|
44
|
-
// and the 'notion-client' dependency.
|
|
45
|
-
// This logic was mostly taken from react-notion-x (sister project of notion-client).
|
|
46
88
|
function getColumnWidth(block) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const blockResult = recordMap.block[blockId];
|
|
56
|
-
// ENHANCE: could we use https://github.com/NotionX/react-notion-x/tree/master/packages/notion-types
|
|
57
|
-
// to get away from "any", which might be particularly helpful in the future
|
|
58
|
-
// since this is using the unofficial (reverse engineered?) API.
|
|
59
|
-
const columnFormat = (_a = blockResult === null || blockResult === void 0 ? void 0 : blockResult.value) === null || _a === void 0 ? void 0 : _a.format;
|
|
60
|
-
const columnRatio = (columnFormat === null || columnFormat === void 0 ? void 0 : columnFormat.column_ratio) || 0.5;
|
|
61
|
-
const parentBlock = (_c = recordMap.block[(_b = blockResult === null || blockResult === void 0 ? void 0 : blockResult.value) === null || _b === void 0 ? void 0 : _b.parent_id]) === null || _c === void 0 ? void 0 : _c.value;
|
|
62
|
-
// I'm not sure why we wouldn't get a parent, but the react-notion-x has
|
|
63
|
-
// this fallback to a guess based on the columnRatio.
|
|
64
|
-
const columnCount = ((_d = parentBlock === null || parentBlock === void 0 ? void 0 : parentBlock.content) === null || _d === void 0 ? void 0 : _d.length) || Math.max(2, Math.ceil(1.0 / columnRatio));
|
|
65
|
-
const spacerWidth = `min(32px, 4vw)`; // This matches the value in css for 'notion-spacer'.
|
|
66
|
-
return `calc((100% - (${spacerWidth} * ${columnCount - 1})) * ${columnRatio})`;
|
|
67
|
-
});
|
|
89
|
+
var _a, _b;
|
|
90
|
+
const columnRatio = (_b = (_a = normalizedColumnRatioById.get(block.id)) !== null && _a !== void 0 ? _a : getRawColumnRatio(block)) !== null && _b !== void 0 ? _b : 0.5;
|
|
91
|
+
// The spacer width depends on how many sibling columns are present. We record
|
|
92
|
+
// that when the parent column_list is converted, then fall back to the older
|
|
93
|
+
// estimate when a column is converted without that context.
|
|
94
|
+
const columnCount = columnCountById.get(block.id) || Math.max(2, Math.ceil(1.0 / columnRatio));
|
|
95
|
+
const spacerWidth = `min(32px, 4vw)`; // This matches the value in css for 'notion-spacer'.
|
|
96
|
+
return `calc((100% - (${spacerWidth} * ${columnCount - 1})) * ${columnRatio})`;
|
|
68
97
|
}
|
|
@@ -37,6 +37,90 @@ function getResults(children) {
|
|
|
37
37
|
}
|
|
38
38
|
const columnWrapperStart = "<div class='notion-column' style=\\{\\{width: '.*?'\\}\\}>\\n\\n";
|
|
39
39
|
const columnWrapperEnd = "\\n\\n<\\/div><div className='notion-spacer'><\\/div>";
|
|
40
|
+
test("getColumnWidth preserves docs-compliant normalized ratios", () => {
|
|
41
|
+
(0, ColumnTransformer_1.rememberColumnListChildren)([
|
|
42
|
+
{
|
|
43
|
+
id: "column-1",
|
|
44
|
+
type: "column",
|
|
45
|
+
column: {
|
|
46
|
+
width_ratio: 0.25,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "column-2",
|
|
51
|
+
type: "column",
|
|
52
|
+
column: {
|
|
53
|
+
width_ratio: 0.75,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
const width = (0, ColumnTransformer_1.getColumnWidth)({
|
|
58
|
+
id: "column-1",
|
|
59
|
+
type: "column",
|
|
60
|
+
column: {
|
|
61
|
+
width_ratio: 0.25,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
expect(width).toBe("calc((100% - (min(32px, 4vw) * 1)) * 0.25)");
|
|
65
|
+
});
|
|
66
|
+
test("getColumnWidth normalizes missing ratios as equal weights", () => {
|
|
67
|
+
(0, ColumnTransformer_1.rememberColumnListChildren)([
|
|
68
|
+
{
|
|
69
|
+
id: "column-1",
|
|
70
|
+
type: "column",
|
|
71
|
+
column: {},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "column-2",
|
|
75
|
+
type: "column",
|
|
76
|
+
column: {},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "column-3",
|
|
80
|
+
type: "column",
|
|
81
|
+
column: {},
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
const width = (0, ColumnTransformer_1.getColumnWidth)({
|
|
85
|
+
id: "column-2",
|
|
86
|
+
type: "column",
|
|
87
|
+
column: {},
|
|
88
|
+
});
|
|
89
|
+
expect(width).toBe("calc((100% - (min(32px, 4vw) * 2)) * 0.3333333333333333)");
|
|
90
|
+
});
|
|
91
|
+
test("getColumnWidth normalizes mixed explicit and missing ratios", () => {
|
|
92
|
+
(0, ColumnTransformer_1.rememberColumnListChildren)([
|
|
93
|
+
{
|
|
94
|
+
id: "column-1",
|
|
95
|
+
type: "column",
|
|
96
|
+
column: {
|
|
97
|
+
width_ratio: 1,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "column-2",
|
|
102
|
+
type: "column",
|
|
103
|
+
column: {
|
|
104
|
+
width_ratio: 0.375,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "column-3",
|
|
109
|
+
type: "column",
|
|
110
|
+
column: {
|
|
111
|
+
width_ratio: 1,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
const width = (0, ColumnTransformer_1.getColumnWidth)({
|
|
116
|
+
id: "column-2",
|
|
117
|
+
type: "column",
|
|
118
|
+
column: {
|
|
119
|
+
width_ratio: 0.375,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
expect(width).toBe("calc((100% - (min(32px, 4vw) * 2)) * 0.15789473684210525)");
|
|
123
|
+
});
|
|
40
124
|
if (runTestsWhichRequireAnyValidApiKey) {
|
|
41
125
|
columnBlock.has_children = true;
|
|
42
126
|
test("requires API key - column with paragraph", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
@@ -33,6 +33,7 @@ beforeEach(() => {
|
|
|
33
33
|
},
|
|
34
34
|
has_children: false,
|
|
35
35
|
archived: false,
|
|
36
|
+
in_trash: false,
|
|
36
37
|
type: "paragraph",
|
|
37
38
|
paragraph: {
|
|
38
39
|
rich_text: [
|
|
@@ -51,6 +52,7 @@ beforeEach(() => {
|
|
|
51
52
|
href: null,
|
|
52
53
|
},
|
|
53
54
|
],
|
|
55
|
+
icon: null,
|
|
54
56
|
color: "default",
|
|
55
57
|
},
|
|
56
58
|
},
|
|
@@ -73,6 +75,7 @@ beforeEach(() => {
|
|
|
73
75
|
},
|
|
74
76
|
has_children: false,
|
|
75
77
|
archived: false,
|
|
78
|
+
in_trash: false,
|
|
76
79
|
type: "paragraph",
|
|
77
80
|
paragraph: {
|
|
78
81
|
rich_text: [
|
|
@@ -91,6 +94,7 @@ beforeEach(() => {
|
|
|
91
94
|
href: null,
|
|
92
95
|
},
|
|
93
96
|
],
|
|
97
|
+
icon: null,
|
|
94
98
|
color: "default",
|
|
95
99
|
},
|
|
96
100
|
},
|
|
@@ -113,8 +117,9 @@ beforeEach(() => {
|
|
|
113
117
|
},
|
|
114
118
|
has_children: false,
|
|
115
119
|
archived: false,
|
|
120
|
+
in_trash: false,
|
|
116
121
|
type: "paragraph",
|
|
117
|
-
paragraph: { rich_text: [], color: "default" },
|
|
122
|
+
paragraph: { rich_text: [], icon: null, color: "default" },
|
|
118
123
|
},
|
|
119
124
|
];
|
|
120
125
|
});
|
|
@@ -4,22 +4,47 @@ exports.standardInternalLinkConversion = void 0;
|
|
|
4
4
|
exports.convertInternalUrl = convertInternalUrl;
|
|
5
5
|
exports.parseLinkId = parseLinkId;
|
|
6
6
|
const log_1 = require("../log");
|
|
7
|
+
const kNotionUrlRegExp = /^https?:\/\/(?:www\.)?notion\.so\/|^https?:\/\/app\.notion\.com\//;
|
|
8
|
+
function getLegacyTrailingSegment(pathOrId) {
|
|
9
|
+
const trimmedPath = pathOrId.replace(/^\/+|\/+$/g, "");
|
|
10
|
+
const lastPathSegment = trimmedPath.split("/").at(-1);
|
|
11
|
+
if (!lastPathSegment)
|
|
12
|
+
return undefined;
|
|
13
|
+
const trailingDashSegment = lastPathSegment.split("-").at(-1);
|
|
14
|
+
return trailingDashSegment || lastPathSegment;
|
|
15
|
+
}
|
|
16
|
+
function normalizeLinkBaseId(baseLinkId) {
|
|
17
|
+
const withoutQuery = baseLinkId.split("?")[0];
|
|
18
|
+
if (kNotionUrlRegExp.test(withoutQuery)) {
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(withoutQuery);
|
|
21
|
+
const trimmedPath = url.pathname.replace(/^\/+|\/+$/g, "");
|
|
22
|
+
const appPathWithoutPrefix = trimmedPath.startsWith("p/")
|
|
23
|
+
? trimmedPath.substring(2)
|
|
24
|
+
: trimmedPath;
|
|
25
|
+
return (getLegacyTrailingSegment(appPathWithoutPrefix) || appPathWithoutPrefix);
|
|
26
|
+
}
|
|
27
|
+
catch (_a) {
|
|
28
|
+
return withoutQuery;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const withoutLeadingSlash = withoutQuery.replace(/^\/+/, "");
|
|
32
|
+
return withoutLeadingSlash;
|
|
33
|
+
}
|
|
7
34
|
// converts a url to a local link, if it is a link to a page in the Notion site
|
|
8
35
|
// only here for plugins, notion won't normally be giving us raw urls (at least not that I've noticed)
|
|
9
36
|
// If it finds a URL but can't find the page it points to, it will return undefined.
|
|
10
37
|
// If it doesn't find a match at all, it returns undefined.
|
|
11
38
|
function convertInternalUrl(context, url) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
if (match === null) {
|
|
39
|
+
const { baseLinkId } = parseLinkId(url);
|
|
40
|
+
if (baseLinkId === url) {
|
|
15
41
|
(0, log_1.warning)(`[standardInternalLinkConversion] Could not parse link ${url} as a Notion URL`);
|
|
16
42
|
return undefined;
|
|
17
43
|
}
|
|
18
|
-
const id = match[1];
|
|
19
44
|
const pages = context.pages;
|
|
20
45
|
// find the page where pageId matches hrefFromNotion
|
|
21
46
|
const targetPage = pages.find(p => {
|
|
22
|
-
return p.matchesLinkId(
|
|
47
|
+
return p.matchesLinkId(baseLinkId);
|
|
23
48
|
});
|
|
24
49
|
if (!targetPage) {
|
|
25
50
|
// About this situation. See https://github.com/sillsdev/docu-notion/issues/9
|
|
@@ -30,8 +55,8 @@ function convertInternalUrl(context, url) {
|
|
|
30
55
|
}
|
|
31
56
|
// handles the whole markdown link, including the label
|
|
32
57
|
function convertInternalLink(context, markdownLink) {
|
|
33
|
-
// match both [foo](/123) and [bar](https://
|
|
34
|
-
const linkRegExp = /\[([^\]]+)?\]\((
|
|
58
|
+
// match both [foo](/123) and [bar](https://app.notion.com/p/123) mention-style links
|
|
59
|
+
const linkRegExp = /\[([^\]]+)?\]\(([^)]+)\)/;
|
|
35
60
|
const match = linkRegExp.exec(markdownLink);
|
|
36
61
|
if (match === null) {
|
|
37
62
|
(0, log_1.warning)(`[standardInternalLinkConversion] Could not parse link ${markdownLink}`);
|
|
@@ -84,11 +109,11 @@ function parseLinkId(fullLinkId) {
|
|
|
84
109
|
const iHash = fullLinkId.indexOf("#");
|
|
85
110
|
if (iHash >= 0) {
|
|
86
111
|
return {
|
|
87
|
-
baseLinkId: fullLinkId.substring(0, iHash),
|
|
112
|
+
baseLinkId: normalizeLinkBaseId(fullLinkId.substring(0, iHash)),
|
|
88
113
|
fragmentId: fullLinkId.substring(iHash),
|
|
89
114
|
};
|
|
90
115
|
}
|
|
91
|
-
return { baseLinkId: fullLinkId, fragmentId: "" };
|
|
116
|
+
return { baseLinkId: normalizeLinkBaseId(fullLinkId), fragmentId: "" };
|
|
92
117
|
}
|
|
93
118
|
exports.standardInternalLinkConversion = {
|
|
94
119
|
name: "standard internal link conversion",
|
|
@@ -98,8 +123,9 @@ exports.standardInternalLinkConversion = {
|
|
|
98
123
|
// Raw links come in without a leading slash, e.g. [link_to_page](4a6de8c0-b90b-444b-8a7b-d534d6ec71a4)
|
|
99
124
|
// Inline links come in with a leading slash, e.g. [pointer to the introduction](/4a6de8c0b90b444b8a7bd534d6ec71a4)
|
|
100
125
|
// "Mention" links come in as full URLs, e.g. [link_to_page](https://www.notion.so/62f1187010214b0883711a1abb277d31)
|
|
126
|
+
// Newer Notion links can also use app.notion.com, including /p/<page-id> URLs.
|
|
101
127
|
// YOu can create them either with @+the name of a page, or by pasting a URL and then selecting the "Mention" option.
|
|
102
|
-
match: /\[([^\]]+)?\]\((?!mailto:)(https
|
|
128
|
+
match: /\[([^\]]+)?\]\((?!mailto:)(https?:\/\/(?:www\.)?notion\.so\/[^),^/]+|https?:\/\/app\.notion\.com\/(?:p\/)?[^),^/]+|\/?[^),^/]+)\)/,
|
|
103
129
|
convert: convertInternalLink,
|
|
104
130
|
},
|
|
105
131
|
};
|
|
@@ -75,6 +75,52 @@ test("mention-style link to an existing page", () => __awaiter(void 0, void 0, v
|
|
|
75
75
|
}, targetPage);
|
|
76
76
|
expect(results.trim()).toBe(`[foo](/${targetPageId})`);
|
|
77
77
|
}));
|
|
78
|
+
test("mention-style link using app.notion.com/p resolves to a local page", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
79
|
+
const targetPageId = "123456781234123412341234567890ab";
|
|
80
|
+
const targetPage = (0, pluginTestRun_1.makeSamplePageObject)({
|
|
81
|
+
slug: undefined,
|
|
82
|
+
name: "Hello World",
|
|
83
|
+
id: "12345678-1234-1234-1234-1234567890ab",
|
|
84
|
+
});
|
|
85
|
+
const results = yield getMarkdown({
|
|
86
|
+
type: "paragraph",
|
|
87
|
+
paragraph: {
|
|
88
|
+
rich_text: [
|
|
89
|
+
{
|
|
90
|
+
type: "mention",
|
|
91
|
+
mention: {
|
|
92
|
+
type: "page",
|
|
93
|
+
page: {
|
|
94
|
+
id: targetPage.pageId,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
annotations: {
|
|
98
|
+
bold: false,
|
|
99
|
+
italic: false,
|
|
100
|
+
strikethrough: false,
|
|
101
|
+
underline: false,
|
|
102
|
+
code: false,
|
|
103
|
+
color: "default",
|
|
104
|
+
},
|
|
105
|
+
plain_text: "foo",
|
|
106
|
+
href: `https://app.notion.com/p/${targetPageId}`,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
color: "default",
|
|
110
|
+
},
|
|
111
|
+
}, targetPage);
|
|
112
|
+
expect(results.trim()).toBe(`[foo](/12345678-1234-1234-1234-1234567890ab)`);
|
|
113
|
+
}));
|
|
114
|
+
test("parseLinkId extracts the page id from app.notion.com URLs", () => {
|
|
115
|
+
expect((0, internalLinks_1.parseLinkId)("https://app.notion.com/p/123456781234123412341234567890ab#heading")).toEqual({
|
|
116
|
+
baseLinkId: "123456781234123412341234567890ab",
|
|
117
|
+
fragmentId: "#heading",
|
|
118
|
+
});
|
|
119
|
+
expect((0, internalLinks_1.parseLinkId)("https://app.notion.com/Interesting-page-123456781234123412341234567890ab")).toEqual({
|
|
120
|
+
baseLinkId: "123456781234123412341234567890ab",
|
|
121
|
+
fragmentId: "",
|
|
122
|
+
});
|
|
123
|
+
});
|
|
78
124
|
test("link to an existing page on this site that has no slug", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
79
125
|
const targetPageId = "123";
|
|
80
126
|
const targetPage = (0, pluginTestRun_1.makeSamplePageObject)({
|
package/dist/pull.js
CHANGED
|
@@ -54,11 +54,12 @@ const images_1 = require("./images");
|
|
|
54
54
|
const Path = __importStar(require("path"));
|
|
55
55
|
const log_1 = require("./log");
|
|
56
56
|
const transform_1 = require("./transform");
|
|
57
|
-
const limiter_1 = require("limiter");
|
|
58
57
|
const client_1 = require("@notionhq/client");
|
|
58
|
+
const limiter_1 = require("limiter");
|
|
59
59
|
const process_1 = require("process");
|
|
60
60
|
const configuration_1 = require("./config/configuration");
|
|
61
61
|
const internalLinks_1 = require("./plugins/internalLinks");
|
|
62
|
+
const kNotionApiVersion = "2026-03-11";
|
|
62
63
|
let layoutStrategy;
|
|
63
64
|
let notionToMarkdown;
|
|
64
65
|
const pages = new Array();
|
|
@@ -89,9 +90,7 @@ function notionPull(options) {
|
|
|
89
90
|
(0, log_1.info)("Connecting to Notion...");
|
|
90
91
|
// Do a quick test to see if we can connect to the root so that we can give a better error than just a generic "could not find page" one.
|
|
91
92
|
try {
|
|
92
|
-
yield
|
|
93
|
-
yield notionClient.pages.retrieve({ page_id: options.rootPage });
|
|
94
|
-
}));
|
|
93
|
+
yield notionClient.pages.retrieve({ page_id: options.rootPage });
|
|
95
94
|
}
|
|
96
95
|
catch (e) {
|
|
97
96
|
(0, log_1.error)(`docu-notion could not retrieve the root page from Notion. \r\na) Check that the root page id really is "${options.rootPage}".\r\nb) Check that your Notion API token (the "Integration Secret") is correct. It starts with "${optionsForLogging.notionToken}".\r\nc) Check that your root page includes your "integration" in its "connections".\r\nThis internal error message may help:\r\n ${e.message}`);
|
|
@@ -223,13 +222,31 @@ const notionLimiter = new limiter_1.RateLimiter({
|
|
|
223
222
|
let notionClient;
|
|
224
223
|
function getPageMetadata(id) {
|
|
225
224
|
return __awaiter(this, void 0, void 0, function* () {
|
|
226
|
-
return yield
|
|
227
|
-
|
|
228
|
-
page_id: id,
|
|
229
|
-
});
|
|
225
|
+
return yield notionClient.pages.retrieve({
|
|
226
|
+
page_id: id,
|
|
230
227
|
});
|
|
231
228
|
});
|
|
232
229
|
}
|
|
230
|
+
function isRetryableNotionError(error) {
|
|
231
|
+
const message = String((error === null || error === void 0 ? void 0 : error.message) || "");
|
|
232
|
+
return ((error === null || error === void 0 ? void 0 : error.code) === "notionhq_client_request_timeout" ||
|
|
233
|
+
(error === null || error === void 0 ? void 0 : error.code) === "notionhq_client_response_error" ||
|
|
234
|
+
(error === null || error === void 0 ? void 0 : error.code) === "service_unavailable" ||
|
|
235
|
+
(error === null || error === void 0 ? void 0 : error.code) === client_1.APIErrorCode.RateLimited ||
|
|
236
|
+
message.includes("timeout") ||
|
|
237
|
+
message.includes("Timeout") ||
|
|
238
|
+
message.includes("limit") ||
|
|
239
|
+
message.includes("Limit"));
|
|
240
|
+
}
|
|
241
|
+
function getRetryDelayMilliseconds(error, retryIndex) {
|
|
242
|
+
var _a, _b;
|
|
243
|
+
const retryAfterHeader = (_b = (_a = error === null || error === void 0 ? void 0 : error.headers) === null || _a === void 0 ? void 0 : _a.get) === null || _b === void 0 ? void 0 : _b.call(_a, "retry-after");
|
|
244
|
+
const retryAfterSeconds = Number.parseInt(retryAfterHeader || "", 10);
|
|
245
|
+
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
|
|
246
|
+
return retryAfterSeconds * 1000;
|
|
247
|
+
}
|
|
248
|
+
return (retryIndex + 1) * 1000;
|
|
249
|
+
}
|
|
233
250
|
// While everything works fine locally, on Github Actions we are getting a lot of timeouts, so
|
|
234
251
|
// we're trying this extra retry-able wrapper.
|
|
235
252
|
function executeWithRateLimitAndRetries(label, asyncFunction) {
|
|
@@ -243,16 +260,10 @@ function executeWithRateLimitAndRetries(label, asyncFunction) {
|
|
|
243
260
|
}
|
|
244
261
|
catch (e) {
|
|
245
262
|
lastException = e;
|
|
246
|
-
if ((e
|
|
247
|
-
e
|
|
248
|
-
e.message.
|
|
249
|
-
|
|
250
|
-
e.message.includes("Limit") ||
|
|
251
|
-
(e === null || e === void 0 ? void 0 : e.code) === "notionhq_client_response_error" ||
|
|
252
|
-
(e === null || e === void 0 ? void 0 : e.code) === "service_unavailable") {
|
|
253
|
-
const secondsToWait = i + 1;
|
|
254
|
-
(0, log_1.warning)(`While doing "${label}", got error "${e.message}". Will retry after ${secondsToWait}s...`);
|
|
255
|
-
yield new Promise(resolve => setTimeout(resolve, 1000 * secondsToWait));
|
|
263
|
+
if (isRetryableNotionError(e)) {
|
|
264
|
+
const millisecondsToWait = getRetryDelayMilliseconds(e, i);
|
|
265
|
+
(0, log_1.warning)(`While doing "${label}", got error "${e.message}". Will retry after ${millisecondsToWait / 1000}s...`);
|
|
266
|
+
yield new Promise(resolve => setTimeout(resolve, millisecondsToWait));
|
|
256
267
|
}
|
|
257
268
|
else {
|
|
258
269
|
throw e;
|
|
@@ -271,9 +282,12 @@ function rateLimit() {
|
|
|
271
282
|
yield notionLimiter.removeTokens(1);
|
|
272
283
|
});
|
|
273
284
|
}
|
|
285
|
+
function isFullBlockFromChildrenList(block) {
|
|
286
|
+
return "type" in block;
|
|
287
|
+
}
|
|
274
288
|
function getBlockChildren(id) {
|
|
275
289
|
return __awaiter(this, void 0, void 0, function* () {
|
|
276
|
-
var _a
|
|
290
|
+
var _a;
|
|
277
291
|
// we can only get so many responses per call, so we set this to
|
|
278
292
|
// the first response we get, then keep adding to its array of blocks
|
|
279
293
|
// with each subsequent response
|
|
@@ -282,11 +296,9 @@ function getBlockChildren(id) {
|
|
|
282
296
|
// Note: there is a now a collectPaginatedAPI() in the notion client, so
|
|
283
297
|
// we could switch to using that (I don't know if it does rate limiting?)
|
|
284
298
|
do {
|
|
285
|
-
const response = yield
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
block_id: id,
|
|
289
|
-
});
|
|
299
|
+
const response = yield notionClient.blocks.children.list({
|
|
300
|
+
start_cursor,
|
|
301
|
+
block_id: id,
|
|
290
302
|
});
|
|
291
303
|
if (!overallResult) {
|
|
292
304
|
overallResult = response;
|
|
@@ -296,18 +308,33 @@ function getBlockChildren(id) {
|
|
|
296
308
|
}
|
|
297
309
|
start_cursor = response === null || response === void 0 ? void 0 : response.next_cursor;
|
|
298
310
|
} while (start_cursor != null);
|
|
299
|
-
if ((_a = overallResult === null || overallResult === void 0 ? void 0 : overallResult.results) === null || _a === void 0 ? void 0 : _a.some(b => !(
|
|
311
|
+
if ((_a = overallResult === null || overallResult === void 0 ? void 0 : overallResult.results) === null || _a === void 0 ? void 0 : _a.some(b => !isFullBlockFromChildrenList(b))) {
|
|
300
312
|
(0, log_1.error)(`The Notion API returned some blocks that were not full blocks. docu-notion does not handle this yet. Please report it.`);
|
|
301
313
|
(0, process_1.exit)(1);
|
|
302
314
|
}
|
|
303
|
-
const result =
|
|
315
|
+
const result = [];
|
|
316
|
+
if (overallResult) {
|
|
317
|
+
const blocks = overallResult.results;
|
|
318
|
+
for (const block of blocks) {
|
|
319
|
+
if (isFullBlockFromChildrenList(block)) {
|
|
320
|
+
result.push(block);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
304
324
|
numberChildrenIfNumberedList(result);
|
|
325
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
305
326
|
return result;
|
|
306
327
|
});
|
|
307
328
|
}
|
|
308
329
|
function initNotionClient(notionToken) {
|
|
309
330
|
notionClient = new client_1.Client({
|
|
310
331
|
auth: notionToken,
|
|
332
|
+
// width_ratio on column blocks is available in newer Notion API versions.
|
|
333
|
+
notionVersion: kNotionApiVersion,
|
|
334
|
+
});
|
|
335
|
+
const originalRequest = notionClient.request.bind(notionClient);
|
|
336
|
+
notionClient.request = (args) => __awaiter(this, void 0, void 0, function* () {
|
|
337
|
+
return yield executeWithRateLimitAndRetries(`${args.method.toUpperCase()} ${args.path}`, () => originalRequest(args));
|
|
311
338
|
});
|
|
312
339
|
return notionClient;
|
|
313
340
|
}
|
package/dist/transform.js
CHANGED
|
@@ -16,7 +16,6 @@ exports.getMarkdownForPage = getMarkdownForPage;
|
|
|
16
16
|
exports.getMarkdownFromNotionBlocks = getMarkdownFromNotionBlocks;
|
|
17
17
|
const chalk_1 = __importDefault(require("chalk"));
|
|
18
18
|
const log_1 = require("./log");
|
|
19
|
-
const pull_1 = require("./pull");
|
|
20
19
|
function getMarkdownForPage(config, context, page) {
|
|
21
20
|
return __awaiter(this, void 0, void 0, function* () {
|
|
22
21
|
(0, log_1.info)(`Reading & converting page ${page.layoutContext}/${page.nameOrTitle} (${chalk_1.default.blue(page.hasExplicitSlug
|
|
@@ -127,17 +126,10 @@ function doTransformsOnMarkdown(context, config, input) {
|
|
|
127
126
|
}
|
|
128
127
|
function doNotionToMarkdown(docunotionContext, blocks) {
|
|
129
128
|
return __awaiter(this, void 0, void 0, function* () {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// Calling blocksToMarkdown can modify the values in the blocks. If it does, and then
|
|
135
|
-
// we have to retry, we end up retrying with the modified values, which
|
|
136
|
-
// causes various issues (like using the transformed image url instead of the original one).
|
|
137
|
-
// Note, currently, we don't do anything else with blocks after this.
|
|
138
|
-
// If that changes, we'll need to figure out a more sophisticated approach.
|
|
139
|
-
JSON.parse(JSON.stringify(blocks)));
|
|
140
|
-
}));
|
|
129
|
+
const mdBlocks = yield docunotionContext.notionToMarkdown.blocksToMarkdown(
|
|
130
|
+
// We need to provide a copy of blocks.
|
|
131
|
+
// Calling blocksToMarkdown can modify the values in the blocks.
|
|
132
|
+
JSON.parse(JSON.stringify(blocks)));
|
|
141
133
|
const markdown = docunotionContext.notionToMarkdown.toMarkdownString(mdBlocks).parent || "";
|
|
142
134
|
return markdown;
|
|
143
135
|
});
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -22,10 +22,9 @@
|
|
|
22
22
|
},
|
|
23
23
|
"//file-type": "have to use this version before they switched to ESM, which gives a compile error related to require()",
|
|
24
24
|
"//chalk@4": "also ESM related problem",
|
|
25
|
-
"//notion-client@4": "also ESM related problem",
|
|
26
25
|
"//note: ts-node": "really is a runtime dependency",
|
|
27
26
|
"dependencies": {
|
|
28
|
-
"@notionhq/client": "
|
|
27
|
+
"@notionhq/client": "5.18.0",
|
|
29
28
|
"chalk": "^4.1.2",
|
|
30
29
|
"commander": "^9.2.0",
|
|
31
30
|
"cosmiconfig": "^8.0.0",
|
|
@@ -34,7 +33,6 @@
|
|
|
34
33
|
"fs-extra": "^10.1.0",
|
|
35
34
|
"limiter": "^2.1.0",
|
|
36
35
|
"markdown-table": "^2.0.0",
|
|
37
|
-
"notion-client": "^4",
|
|
38
36
|
"notion-to-md": "3.1.1",
|
|
39
37
|
"path": "^0.12.7",
|
|
40
38
|
"sanitize-filename": "^1.6.3",
|
|
@@ -43,7 +41,7 @@
|
|
|
43
41
|
"devDependencies": {
|
|
44
42
|
"@types/fs-extra": "^9.0.13",
|
|
45
43
|
"@types/markdown-table": "^2.0.0",
|
|
46
|
-
"@types/node": "^
|
|
44
|
+
"@types/node": "^22.15.21",
|
|
47
45
|
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
|
48
46
|
"@typescript-eslint/parser": "^4.22.0",
|
|
49
47
|
"@vitest/ui": "^0.30.1",
|
|
@@ -92,5 +90,5 @@
|
|
|
92
90
|
"volta": {
|
|
93
91
|
"node": "22.21.0"
|
|
94
92
|
},
|
|
95
|
-
"version": "0.17.0-alpha.
|
|
93
|
+
"version": "0.17.0-alpha.4"
|
|
96
94
|
}
|