@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.
@@ -1,4 +1,4 @@
1
- import { GetPageResponse } from "@notionhq/client/build/src/api-endpoints";
1
+ import { GetPageResponse } from "@notionhq/client";
2
2
  import { ListBlockChildrenResponseResults } from "notion-to-md/build/types";
3
3
  export declare enum PageType {
4
4
  DatabasePage = 0,
@@ -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.parent.type === "database_id"
61
+ return isDatabaseBackedPage(this.metadata)
57
62
  ? PageType.DatabasePage
58
63
  : PageType.Simple;
59
64
  }
@@ -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
  });
@@ -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
- // Enhance: The @notionhq/client, which uses the official API, cannot yet get at column formatting information (column_ratio)
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
- const column_list_promise = column_list_children.map((column) => __awaiter(this, void 0, void 0, function* () { return yield notionToMarkdown.blockToMarkdown(column); }));
22
- const columns = yield Promise.all(column_list_promise);
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
- const notion_client_1 = require("notion-client");
14
- const pull_1 = require("../pull");
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
- //console.log(JSON.stringify(block));
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 = yield Promise.all(columnChildren.map((child) => __awaiter(this, void 0, void 0, function* () { return yield notionToMarkdown.blocksToMarkdown([child]); })));
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 = yield getColumnWidth(block);
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
- return __awaiter(this, void 0, void 0, function* () {
48
- var _a, _b, _c, _d;
49
- const unofficialNotionClient = new notion_client_1.NotionAPI();
50
- const blockId = block.id;
51
- const recordMap = yield (0, pull_1.executeWithRateLimitAndRetries)(`unofficialNotionClient.getPage(${blockId}) in getColumnWidth()`, () => {
52
- // Yes, it is odd to call 'getPage' for a block, but that's how we access the format info.
53
- return unofficialNotionClient.getPage(blockId);
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 kGetIDFromNotionURL = /https:\/\/www\.notion\.so\S+-([a-z,0-9]+)+.*/;
13
- const match = kGetIDFromNotionURL.exec(url);
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(id);
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://www.notion.so/123) <-- the "mention" link style
34
- const linkRegExp = /\[([^\]]+)?\]\((?:https?:\/\/www\.notion\.so\/|\/)?([^),^/]+)\)/g;
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:\/\/www\.notion\.so\/[^),^/]+|\/?[^),^/]+)\)/,
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 executeWithRateLimitAndRetries("retrieving root page", () => __awaiter(this, void 0, void 0, function* () {
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 executeWithRateLimitAndRetries(`pages.retrieve(${id})`, () => {
227
- return notionClient.pages.retrieve({
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 === null || e === void 0 ? void 0 : e.code) === "notionhq_client_request_timeout" ||
247
- e.message.includes("timeout") ||
248
- e.message.includes("Timeout") ||
249
- e.message.includes("limit") ||
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, _b;
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 executeWithRateLimitAndRetries(`getBlockChildren(${id})`, () => {
286
- return notionClient.blocks.children.list({
287
- start_cursor: start_cursor,
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 => !(0, client_1.isFullBlock)(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 = (_b = overallResult === null || overallResult === void 0 ? void 0 : overallResult.results) !== null && _b !== void 0 ? _b : [];
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
- let mdBlocks;
131
- yield (0, pull_1.executeWithRateLimitAndRetries)("notionToMarkdown.blocksToMarkdown", () => __awaiter(this, void 0, void 0, function* () {
132
- mdBlocks = yield docunotionContext.notionToMarkdown.blocksToMarkdown(
133
- // We need to provide a copy of blocks.
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
@@ -1,4 +1,4 @@
1
- import { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
1
+ import { BlockObjectResponse } from "@notionhq/client";
2
2
  export type NotionBlock = BlockObjectResponse;
3
3
  export type ICounts = {
4
4
  output_normally: number;
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": "2.2.3",
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": "^18.15.11",
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.2"
93
+ "version": "0.17.0-alpha.4"
96
94
  }