ntion 0.2.0 → 0.4.0
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/README.md +18 -2
- package/dist/audit/log.d.ts +4 -0
- package/dist/cli.js +111 -11
- package/dist/commands/mutation.d.ts +2 -0
- package/dist/commands/mutation.js +78 -2
- package/dist/config/paths.d.ts +1 -0
- package/dist/config/paths.js +3 -0
- package/dist/idempotency/store.d.ts +8 -2
- package/dist/idempotency/store.js +143 -55
- package/dist/notion/markdown.js +74 -0
- package/dist/notion/repository.d.ts +4 -0
- package/dist/notion/repository.js +202 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,16 @@ A single "get all my tasks" workflow tells the whole story:
|
|
|
35
35
|
2. **No schema bloat** — MCP's database fetch includes ~2 KB of SQLite DDL, ~800 B of XML boilerplate, and ~1.4 KB of base64 `collectionPropertyOption://` URLs that are never used for reads. ntion returns only actionable data.
|
|
36
36
|
3. **Markdown-first** — Page content defaults to markdown, matching what agents actually consume. No manual format negotiation needed.
|
|
37
37
|
|
|
38
|
+
### Operations the official MCP can't do
|
|
39
|
+
|
|
40
|
+
The official Notion MCP server has no block delete tool. ntion does:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
ntion blocks delete --block-ids <block_id>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Delete one or many blocks in a single call — useful for cleaning up content, removing broken blocks, or precise page editing.
|
|
47
|
+
|
|
38
48
|
## Agent skill
|
|
39
49
|
|
|
40
50
|
ntion ships with an [agent skill](https://docs.anthropic.com/en/docs/claude-code/skills) that teaches AI agents how to use the CLI. Install it with:
|
|
@@ -138,9 +148,11 @@ ntion pages unrelate --from-id <page_id> --property Project --to-id <page_id>
|
|
|
138
148
|
```bash
|
|
139
149
|
# Read as markdown (default)
|
|
140
150
|
ntion blocks get --id <page_or_block_id> --depth 1
|
|
151
|
+
ntion blocks get --id <page_or_block_id> --view full
|
|
152
|
+
# blocks get uses --view (markdown|compact|full)
|
|
141
153
|
|
|
142
154
|
# Append markdown content
|
|
143
|
-
ntion blocks append --id <page_or_block_id> --markdown
|
|
155
|
+
ntion blocks append --id <page_or_block_id> --markdown $'# Title\n\nHello'
|
|
144
156
|
ntion blocks append --id <page_or_block_id> --markdown-file ./notes.md
|
|
145
157
|
|
|
146
158
|
# Surgical insertion
|
|
@@ -157,6 +169,10 @@ ntion blocks replace-range \
|
|
|
157
169
|
--start-selector-json '{"where":{"text_contains":"Start"}}' \
|
|
158
170
|
--end-selector-json '{"where":{"text_contains":"End"}}' \
|
|
159
171
|
--markdown "Replacement content"
|
|
172
|
+
|
|
173
|
+
# Delete blocks (not available in the official Notion MCP)
|
|
174
|
+
ntion blocks delete --block-ids <block_id>
|
|
175
|
+
ntion blocks delete --block-ids <id1> <id2> <id3>
|
|
160
176
|
```
|
|
161
177
|
|
|
162
178
|
### Health check
|
|
@@ -183,7 +199,7 @@ Compact, deterministic, easy to parse — by humans or machines.
|
|
|
183
199
|
|
|
184
200
|
- **Generic** — works with any Notion workspace, no hardcoded schema assumptions
|
|
185
201
|
- **Compact** — deterministic JSON envelopes, minimal bytes per response
|
|
186
|
-
- **Safe** — automatic idempotency for all mutations, built-in conflict detection
|
|
202
|
+
- **Safe** — automatic idempotency for all mutations, built-in conflict detection and verify-first recovery
|
|
187
203
|
- **Fast** — zero native dependencies, internal schema caching, batch operations
|
|
188
204
|
- **Agent-friendly** — designed for AI agents that pay per token
|
|
189
205
|
|
package/dist/audit/log.d.ts
CHANGED
|
@@ -6,5 +6,9 @@ export interface AuditEvent {
|
|
|
6
6
|
target_ids?: string[];
|
|
7
7
|
ok: boolean;
|
|
8
8
|
timestamp: string;
|
|
9
|
+
recovery_attempted?: boolean;
|
|
10
|
+
recovery_succeeded?: boolean;
|
|
11
|
+
idempotency_persist_degraded?: boolean;
|
|
12
|
+
outcome_uncertain?: boolean;
|
|
9
13
|
}
|
|
10
14
|
export declare function appendAuditLog(event: AuditEvent): Promise<void>;
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { Command } from "commander";
|
|
3
|
+
import { Command, Option } from "commander";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { stdin as input, stdout as output } from "node:process";
|
|
6
6
|
import { executeMutationWithIdempotency } from "./commands/mutation.js";
|
|
@@ -10,7 +10,7 @@ import { getConfigPath } from "./config/paths.js";
|
|
|
10
10
|
import { buildInitialAuthConfig, loadConfigOrNull, saveConfig } from "./config/store.js";
|
|
11
11
|
import { CliError } from "./errors/cli-error.js";
|
|
12
12
|
import { markdownToBlocks } from "./notion/markdown.js";
|
|
13
|
-
import { appendBlocks, archivePage, createPage, createPagesBulk, getBlocks, getDataSource, getDataSourceSchema, getPage, insertBlocks, listDataSources, queryDataSourcePages, replaceBlockRange, searchWorkspace, selectBlocks, setRelation, unarchivePage, updatePage, } from "./notion/repository.js";
|
|
13
|
+
import { appendBlocks, archivePage, areBlocksDeleted, createPage, createPagesBulk, deleteBlocks, getBlocks, getDataSource, getDataSourceSchema, getPage, insertBlocks, listDataSources, queryDataSourcePages, replaceBlockRange, searchWorkspace, selectBlocks, setRelation, unarchivePage, updatePage, } from "./notion/repository.js";
|
|
14
14
|
import { parseJsonOption } from "./utils/json.js";
|
|
15
15
|
const ROOT_HELP_EPILOG = [
|
|
16
16
|
"",
|
|
@@ -59,9 +59,15 @@ const BLOCKS_APPEND_HELP_EPILOG = [
|
|
|
59
59
|
" Provide exactly one of --blocks-json, --markdown, or --markdown-file.",
|
|
60
60
|
"",
|
|
61
61
|
"Examples:",
|
|
62
|
-
" ntion blocks append --id <id> --markdown
|
|
62
|
+
" ntion blocks append --id <id> --markdown $'# Heading\\n\\nBody'",
|
|
63
63
|
" ntion blocks append --id <id> --markdown-file ./notes.md",
|
|
64
64
|
].join("\n");
|
|
65
|
+
const BLOCKS_GET_HELP_EPILOG = [
|
|
66
|
+
"",
|
|
67
|
+
"View vs Return-View:",
|
|
68
|
+
" blocks get uses --view <markdown|compact|full>.",
|
|
69
|
+
" mutation commands use --return-view <compact|full> for page/data outputs.",
|
|
70
|
+
].join("\n");
|
|
65
71
|
const BLOCKS_INSERT_HELP_EPILOG = [
|
|
66
72
|
"",
|
|
67
73
|
"Positioning:",
|
|
@@ -104,7 +110,7 @@ function resolveBlockReadFormat(input, fallback) {
|
|
|
104
110
|
if (value === "markdown" || value === "compact" || value === "full") {
|
|
105
111
|
return value;
|
|
106
112
|
}
|
|
107
|
-
throw new CliError("invalid_input", "Block
|
|
113
|
+
throw new CliError("invalid_input", "Block view must be markdown, compact, or full.");
|
|
108
114
|
}
|
|
109
115
|
function parseSearchObject(raw) {
|
|
110
116
|
if (!raw) {
|
|
@@ -188,11 +194,26 @@ async function resolveBlocksInput(args) {
|
|
|
188
194
|
return requireArrayJson(args.blocksJson, "blocks-json");
|
|
189
195
|
}
|
|
190
196
|
if (args.markdown) {
|
|
197
|
+
validateMarkdownInput(args.markdown);
|
|
191
198
|
return markdownToBlocks(args.markdown);
|
|
192
199
|
}
|
|
193
200
|
const markdown = await readFile(String(args.markdownFile), "utf8");
|
|
194
201
|
return markdownToBlocks(markdown);
|
|
195
202
|
}
|
|
203
|
+
function validateMarkdownInput(markdown) {
|
|
204
|
+
if (markdown.includes("\\n") && !markdown.includes("\n")) {
|
|
205
|
+
throw new CliError("invalid_input", "--markdown appears to contain literal \\n escapes. Use real newline characters or --markdown-file.");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function relationIncludesTarget(page, propertyName, targetId) {
|
|
209
|
+
const properties = page.properties;
|
|
210
|
+
if (!properties || typeof properties !== "object") {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const relationValue = properties[propertyName];
|
|
214
|
+
return (Array.isArray(relationValue) &&
|
|
215
|
+
relationValue.some((value) => typeof value === "string" && value === targetId));
|
|
216
|
+
}
|
|
196
217
|
function addCommonReadOptions(command) {
|
|
197
218
|
return command
|
|
198
219
|
.option("--view <compact|full>", "response view mode")
|
|
@@ -218,6 +239,7 @@ async function runInteractiveAuthSetup() {
|
|
|
218
239
|
}
|
|
219
240
|
const rl = createInterface({ input, output });
|
|
220
241
|
try {
|
|
242
|
+
console.log("Create a token at https://www.notion.so/profile/integrations\n");
|
|
221
243
|
const response = await rl.question("Paste your Notion integration token: ");
|
|
222
244
|
const trimmed = response.trim();
|
|
223
245
|
if (!trimmed) {
|
|
@@ -254,9 +276,11 @@ async function saveAuthConfigEnv(tokenEnv) {
|
|
|
254
276
|
config_path: getConfigPath(),
|
|
255
277
|
};
|
|
256
278
|
}
|
|
279
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf-8"));
|
|
257
280
|
const program = new Command();
|
|
258
281
|
program
|
|
259
282
|
.name("ntion")
|
|
283
|
+
.version(packageJson.version, "-V, --version", "print version number")
|
|
260
284
|
.description("Token-efficient, workspace-agnostic Notion CLI")
|
|
261
285
|
.showHelpAfterError()
|
|
262
286
|
.addHelpText("after", ROOT_HELP_EPILOG);
|
|
@@ -611,6 +635,16 @@ addCommonMutationOptions(pagesCommand.command("archive").description("Archive a
|
|
|
611
635
|
view,
|
|
612
636
|
fields,
|
|
613
637
|
}),
|
|
638
|
+
recover: async () => {
|
|
639
|
+
const current = await getPage(notion, options.id, "full");
|
|
640
|
+
if (current.archived !== true) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
if (view === "full") {
|
|
644
|
+
return current;
|
|
645
|
+
}
|
|
646
|
+
return getPage(notion, options.id, view, fields);
|
|
647
|
+
},
|
|
614
648
|
});
|
|
615
649
|
return {
|
|
616
650
|
data: {
|
|
@@ -644,6 +678,16 @@ addCommonMutationOptions(pagesCommand.command("unarchive").description("Unarchiv
|
|
|
644
678
|
view,
|
|
645
679
|
fields,
|
|
646
680
|
}),
|
|
681
|
+
recover: async () => {
|
|
682
|
+
const current = await getPage(notion, options.id, "full");
|
|
683
|
+
if (current.archived !== false) {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
if (view === "full") {
|
|
687
|
+
return current;
|
|
688
|
+
}
|
|
689
|
+
return getPage(notion, options.id, view, fields);
|
|
690
|
+
},
|
|
647
691
|
});
|
|
648
692
|
return {
|
|
649
693
|
data: {
|
|
@@ -684,6 +728,16 @@ addCommonMutationOptions(pagesCommand.command("relate").description("Add a relat
|
|
|
684
728
|
view,
|
|
685
729
|
fields,
|
|
686
730
|
}),
|
|
731
|
+
recover: async () => {
|
|
732
|
+
const current = await getPage(notion, options.fromId, "full");
|
|
733
|
+
if (!relationIncludesTarget(current, options.property, options.toId)) {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
if (view === "full") {
|
|
737
|
+
return current;
|
|
738
|
+
}
|
|
739
|
+
return getPage(notion, options.fromId, view, fields);
|
|
740
|
+
},
|
|
687
741
|
});
|
|
688
742
|
return {
|
|
689
743
|
data: {
|
|
@@ -724,6 +778,16 @@ addCommonMutationOptions(pagesCommand.command("unrelate").description("Remove a
|
|
|
724
778
|
view,
|
|
725
779
|
fields,
|
|
726
780
|
}),
|
|
781
|
+
recover: async () => {
|
|
782
|
+
const current = await getPage(notion, options.fromId, "full");
|
|
783
|
+
if (relationIncludesTarget(current, options.property, options.toId)) {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
if (view === "full") {
|
|
787
|
+
return current;
|
|
788
|
+
}
|
|
789
|
+
return getPage(notion, options.fromId, view, fields);
|
|
790
|
+
},
|
|
727
791
|
});
|
|
728
792
|
return {
|
|
729
793
|
data: {
|
|
@@ -733,27 +797,32 @@ addCommonMutationOptions(pagesCommand.command("unrelate").description("Remove a
|
|
|
733
797
|
});
|
|
734
798
|
});
|
|
735
799
|
const blocksCommand = program.command("blocks").description("Block operations");
|
|
736
|
-
blocksCommand
|
|
800
|
+
const blocksGetCommand = blocksCommand
|
|
737
801
|
.command("get")
|
|
738
802
|
.description("Get blocks from a page or block")
|
|
739
803
|
.requiredOption("--id <page_or_block_id>", "Notion page or block ID")
|
|
740
804
|
.option("--max-blocks <n>", "Maximum block count")
|
|
741
805
|
.option("--depth <n>", "Recursion depth", "1")
|
|
742
|
-
.option("--
|
|
806
|
+
.option("--view <markdown|compact|full>", "content view", "markdown")
|
|
807
|
+
.addOption(new Option("--format <markdown|compact|full>").hideHelp())
|
|
743
808
|
.option("--pretty", "pretty-print JSON output")
|
|
744
809
|
.option("--timeout-ms <n>", "request timeout in milliseconds")
|
|
745
810
|
.action(async (options) => {
|
|
746
811
|
await runAction(Boolean(options.pretty), async () => {
|
|
812
|
+
if (options.format) {
|
|
813
|
+
throw new CliError("invalid_input", "--format is no longer supported for blocks get. Use --view <markdown|compact|full>.");
|
|
814
|
+
}
|
|
747
815
|
const { config, notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
|
|
748
816
|
const maxBlocks = parsePositiveInt(options.maxBlocks, "max-blocks", config.defaults.max_blocks);
|
|
749
817
|
const depth = parsePositiveInt(options.depth, "depth", 1);
|
|
750
|
-
const
|
|
751
|
-
const blocks = await getBlocks(notion, options.id, maxBlocks, depth,
|
|
818
|
+
const view = resolveBlockReadFormat(options.view, "markdown");
|
|
819
|
+
const blocks = await getBlocks(notion, options.id, maxBlocks, depth, view);
|
|
752
820
|
return {
|
|
753
821
|
data: blocks,
|
|
754
822
|
};
|
|
755
823
|
});
|
|
756
824
|
});
|
|
825
|
+
blocksGetCommand.addHelpText("after", BLOCKS_GET_HELP_EPILOG);
|
|
757
826
|
const blocksAppendCommand = blocksCommand
|
|
758
827
|
.command("append")
|
|
759
828
|
.description("Append blocks to a page or block")
|
|
@@ -766,12 +835,12 @@ const blocksAppendCommand = blocksCommand
|
|
|
766
835
|
.option("--timeout-ms <n>", "request timeout in milliseconds")
|
|
767
836
|
.action(async (options) => {
|
|
768
837
|
await runAction(Boolean(options.pretty), async (requestId) => {
|
|
769
|
-
const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
|
|
770
838
|
const blocks = await resolveBlocksInput({
|
|
771
839
|
blocksJson: options.blocksJson,
|
|
772
840
|
markdown: options.markdown,
|
|
773
841
|
markdownFile: options.markdownFile,
|
|
774
842
|
});
|
|
843
|
+
const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
|
|
775
844
|
const result = await executeMutationWithIdempotency({
|
|
776
845
|
commandName: options.dryRun ? "blocks.append.dry_run" : "blocks.append",
|
|
777
846
|
requestId,
|
|
@@ -807,13 +876,13 @@ const blocksInsertCommand = blocksCommand
|
|
|
807
876
|
.option("--timeout-ms <n>", "request timeout in milliseconds")
|
|
808
877
|
.action(async (options) => {
|
|
809
878
|
await runAction(Boolean(options.pretty), async (requestId) => {
|
|
810
|
-
const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
|
|
811
879
|
const blocks = await resolveBlocksInput({
|
|
812
880
|
blocksJson: options.blocksJson,
|
|
813
881
|
markdown: options.markdown,
|
|
814
882
|
markdownFile: options.markdownFile,
|
|
815
883
|
});
|
|
816
884
|
const position = resolveInsertPosition(options.position, options.afterId);
|
|
885
|
+
const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
|
|
817
886
|
const result = await executeMutationWithIdempotency({
|
|
818
887
|
commandName: options.dryRun ? "blocks.insert.dry_run" : "blocks.insert",
|
|
819
888
|
requestId,
|
|
@@ -878,7 +947,6 @@ const blocksReplaceRangeCommand = blocksCommand
|
|
|
878
947
|
.option("--timeout-ms <n>", "request timeout in milliseconds")
|
|
879
948
|
.action(async (options) => {
|
|
880
949
|
await runAction(Boolean(options.pretty), async (requestId) => {
|
|
881
|
-
const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
|
|
882
950
|
const blocks = await resolveBlocksInput({
|
|
883
951
|
blocksJson: options.blocksJson,
|
|
884
952
|
markdown: options.markdown,
|
|
@@ -887,6 +955,7 @@ const blocksReplaceRangeCommand = blocksCommand
|
|
|
887
955
|
const startSelector = requireSelectorJson(options.startSelectorJson, "start-selector-json");
|
|
888
956
|
const endSelector = requireSelectorJson(options.endSelectorJson, "end-selector-json");
|
|
889
957
|
const maxBlocks = parsePositiveInt(options.scanMaxBlocks, "scan-max-blocks", 5000);
|
|
958
|
+
const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
|
|
890
959
|
const result = await executeMutationWithIdempotency({
|
|
891
960
|
commandName: options.dryRun ? "blocks.replace_range.dry_run" : "blocks.replace_range",
|
|
892
961
|
requestId,
|
|
@@ -918,6 +987,37 @@ const blocksReplaceRangeCommand = blocksCommand
|
|
|
918
987
|
});
|
|
919
988
|
});
|
|
920
989
|
blocksReplaceRangeCommand.addHelpText("after", BLOCKS_REPLACE_RANGE_HELP_EPILOG);
|
|
990
|
+
blocksCommand
|
|
991
|
+
.command("delete")
|
|
992
|
+
.description("Delete one or more blocks by ID")
|
|
993
|
+
.requiredOption("--block-ids <ids...>", "Block IDs to delete")
|
|
994
|
+
.option("--pretty", "pretty-print JSON output")
|
|
995
|
+
.option("--timeout-ms <n>", "request timeout in milliseconds")
|
|
996
|
+
.action(async (options) => {
|
|
997
|
+
await runAction(Boolean(options.pretty), async (requestId) => {
|
|
998
|
+
const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
|
|
999
|
+
const result = await executeMutationWithIdempotency({
|
|
1000
|
+
commandName: "blocks.delete",
|
|
1001
|
+
requestId,
|
|
1002
|
+
requestShape: { block_ids: options.blockIds },
|
|
1003
|
+
targetIds: options.blockIds,
|
|
1004
|
+
run: () => deleteBlocks(notion, { blockIds: options.blockIds }),
|
|
1005
|
+
recover: async () => {
|
|
1006
|
+
const deleted = await areBlocksDeleted(notion, options.blockIds);
|
|
1007
|
+
if (!deleted) {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
return {
|
|
1011
|
+
deleted_count: options.blockIds.length,
|
|
1012
|
+
deleted_ids: options.blockIds,
|
|
1013
|
+
};
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
return {
|
|
1017
|
+
data: result,
|
|
1018
|
+
};
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
921
1021
|
program
|
|
922
1022
|
.command("doctor")
|
|
923
1023
|
.description("Validate config and auth quickly")
|
|
@@ -13,11 +13,42 @@ async function safeAppendAuditLog(event) {
|
|
|
13
13
|
// Audit logging must not alter mutation outcomes.
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
function getStatus(error) {
|
|
17
|
+
if (!error || typeof error !== "object") {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const status = error.status;
|
|
21
|
+
return typeof status === "number" ? status : undefined;
|
|
22
|
+
}
|
|
23
|
+
function getCode(error) {
|
|
24
|
+
if (!error || typeof error !== "object") {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const code = error.code;
|
|
28
|
+
return typeof code === "string" ? code : undefined;
|
|
29
|
+
}
|
|
30
|
+
function isAmbiguousMutationError(error, override) {
|
|
31
|
+
if (override) {
|
|
32
|
+
return override(error);
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof CliError && error.code === "retryable_upstream") {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
const status = getStatus(error);
|
|
38
|
+
if (status === 429 || (typeof status === "number" && status >= 500)) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return getCode(error) === "internal_error";
|
|
42
|
+
}
|
|
16
43
|
export async function executeMutationWithIdempotency(args) {
|
|
17
44
|
const store = new IdempotencyStore();
|
|
18
45
|
const requestHash = hashObject(args.requestShape);
|
|
19
46
|
const idempotencyKey = buildInternalIdempotencyKey(args.commandName, args.requestShape);
|
|
20
47
|
let ownsReservation = false;
|
|
48
|
+
let recoveryAttempted = false;
|
|
49
|
+
let recoverySucceeded = false;
|
|
50
|
+
let idempotencyPersistDegraded = false;
|
|
51
|
+
let outcomeUncertain = false;
|
|
21
52
|
try {
|
|
22
53
|
let lookup = store.reserve(idempotencyKey, args.commandName, requestHash);
|
|
23
54
|
while (lookup.kind === "pending") {
|
|
@@ -68,8 +99,45 @@ export async function executeMutationWithIdempotency(args) {
|
|
|
68
99
|
return lookup.response;
|
|
69
100
|
}
|
|
70
101
|
ownsReservation = true;
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
let response;
|
|
103
|
+
try {
|
|
104
|
+
response = await args.run();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!isAmbiguousMutationError(error, args.isAmbiguousError)) {
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
recoveryAttempted = true;
|
|
111
|
+
if (args.recover) {
|
|
112
|
+
try {
|
|
113
|
+
const recovered = await args.recover();
|
|
114
|
+
if (recovered !== null) {
|
|
115
|
+
response = recovered;
|
|
116
|
+
recoverySucceeded = true;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
outcomeUncertain = true;
|
|
120
|
+
throw new CliError("retryable_upstream", "Mutation outcome could not be confirmed. Re-read before retrying.", { retryable: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
outcomeUncertain = true;
|
|
125
|
+
throw new CliError("retryable_upstream", "Mutation outcome could not be confirmed. Re-read before retrying.", { retryable: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
outcomeUncertain = true;
|
|
130
|
+
throw new CliError("retryable_upstream", "Mutation outcome could not be confirmed. Re-read before retrying.", { retryable: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
store.complete(idempotencyKey, args.commandName, requestHash, response);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Avoid surfacing internal idempotency persistence failures to callers
|
|
138
|
+
// after the write has already succeeded or been verified.
|
|
139
|
+
idempotencyPersistDegraded = true;
|
|
140
|
+
}
|
|
73
141
|
await safeAppendAuditLog({
|
|
74
142
|
command: args.commandName,
|
|
75
143
|
entity: args.entity,
|
|
@@ -77,6 +145,10 @@ export async function executeMutationWithIdempotency(args) {
|
|
|
77
145
|
idempotency_key: idempotencyKey,
|
|
78
146
|
target_ids: args.targetIds,
|
|
79
147
|
ok: true,
|
|
148
|
+
recovery_attempted: recoveryAttempted,
|
|
149
|
+
recovery_succeeded: recoverySucceeded,
|
|
150
|
+
idempotency_persist_degraded: idempotencyPersistDegraded,
|
|
151
|
+
outcome_uncertain: false,
|
|
80
152
|
timestamp: new Date().toISOString(),
|
|
81
153
|
});
|
|
82
154
|
return response;
|
|
@@ -92,6 +164,10 @@ export async function executeMutationWithIdempotency(args) {
|
|
|
92
164
|
idempotency_key: idempotencyKey,
|
|
93
165
|
target_ids: args.targetIds,
|
|
94
166
|
ok: false,
|
|
167
|
+
recovery_attempted: recoveryAttempted,
|
|
168
|
+
recovery_succeeded: recoverySucceeded,
|
|
169
|
+
idempotency_persist_degraded: idempotencyPersistDegraded,
|
|
170
|
+
outcome_uncertain: outcomeUncertain,
|
|
95
171
|
timestamp: new Date().toISOString(),
|
|
96
172
|
});
|
|
97
173
|
throw error;
|
package/dist/config/paths.d.ts
CHANGED
|
@@ -2,4 +2,5 @@ export declare function getConfigDir(): string;
|
|
|
2
2
|
export declare function getConfigPath(): string;
|
|
3
3
|
export declare function getAuditLogPath(): string;
|
|
4
4
|
export declare function getIdempotencyStorePath(): string;
|
|
5
|
+
export declare function getIdempotencyLockPath(): string;
|
|
5
6
|
export declare function ensureConfigDir(): string;
|
package/dist/config/paths.js
CHANGED
|
@@ -17,6 +17,9 @@ export function getAuditLogPath() {
|
|
|
17
17
|
export function getIdempotencyStorePath() {
|
|
18
18
|
return join(getConfigDir(), "idempotency.json");
|
|
19
19
|
}
|
|
20
|
+
export function getIdempotencyLockPath() {
|
|
21
|
+
return join(getConfigDir(), "idempotency.lock");
|
|
22
|
+
}
|
|
20
23
|
export function ensureConfigDir() {
|
|
21
24
|
const configDir = getConfigDir();
|
|
22
25
|
if (!existsSync(configDir)) {
|
|
@@ -22,13 +22,19 @@ export type IdempotencyReservation = {
|
|
|
22
22
|
};
|
|
23
23
|
export declare class IdempotencyStore {
|
|
24
24
|
private readonly filePath;
|
|
25
|
+
private readonly lockPath;
|
|
25
26
|
constructor(filePath?: string);
|
|
26
27
|
close(): void;
|
|
27
28
|
lookup(idempotencyKey: string, commandName: string, inputHash: string): IdempotencyLookup;
|
|
28
29
|
reserve(idempotencyKey: string, commandName: string, inputHash: string): IdempotencyReservation;
|
|
29
30
|
complete(idempotencyKey: string, commandName: string, inputHash: string, response: unknown): void;
|
|
30
31
|
release(idempotencyKey: string, commandName: string, inputHash: string): void;
|
|
31
|
-
private
|
|
32
|
-
private
|
|
32
|
+
private withLock;
|
|
33
|
+
private acquireLock;
|
|
34
|
+
private releaseLock;
|
|
35
|
+
private tryClearStaleLock;
|
|
36
|
+
private sleepMs;
|
|
37
|
+
private loadDataUnlocked;
|
|
38
|
+
private saveDataUnlocked;
|
|
33
39
|
}
|
|
34
40
|
export declare function buildInternalIdempotencyKey(commandName: string, requestShape: unknown): string;
|
|
@@ -1,87 +1,172 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { ensureConfigDir, getIdempotencyStorePath } from "../config/paths.js";
|
|
1
|
+
import { closeSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { ensureConfigDir, getIdempotencyLockPath, getIdempotencyStorePath } from "../config/paths.js";
|
|
3
3
|
import { CliError } from "../errors/cli-error.js";
|
|
4
4
|
import { hashObject } from "../utils/json.js";
|
|
5
5
|
const PRUNE_TTL_MS = 180_000;
|
|
6
|
+
const LOCK_STALE_MS = 30_000;
|
|
7
|
+
const LOCK_WAIT_TIMEOUT_MS = 35_000;
|
|
8
|
+
const LOCK_POLL_INTERVAL_MS = 30;
|
|
9
|
+
const SLEEP_ARRAY = new Int32Array(new SharedArrayBuffer(4));
|
|
6
10
|
const PENDING_RESPONSE_JSON = JSON.stringify({ __notion_lite_pending: true });
|
|
7
11
|
function compositeKey(idempotencyKey, commandName) {
|
|
8
12
|
return `${idempotencyKey}\0${commandName}`;
|
|
9
13
|
}
|
|
10
14
|
export class IdempotencyStore {
|
|
11
15
|
filePath;
|
|
16
|
+
lockPath;
|
|
12
17
|
constructor(filePath = getIdempotencyStorePath()) {
|
|
13
18
|
ensureConfigDir();
|
|
14
19
|
this.filePath = filePath;
|
|
20
|
+
this.lockPath =
|
|
21
|
+
filePath === getIdempotencyStorePath() ? getIdempotencyLockPath() : `${filePath}.lock`;
|
|
15
22
|
}
|
|
16
23
|
close() {
|
|
17
24
|
// no-op — nothing to tear down for a JSON file store
|
|
18
25
|
}
|
|
19
26
|
lookup(idempotencyKey, commandName, inputHash) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
return this.withLock(() => {
|
|
28
|
+
const data = this.loadDataUnlocked();
|
|
29
|
+
const entry = data[compositeKey(idempotencyKey, commandName)];
|
|
30
|
+
if (!entry) {
|
|
31
|
+
return { kind: "miss" };
|
|
32
|
+
}
|
|
33
|
+
if (entry.inputHash !== inputHash) {
|
|
34
|
+
return { kind: "conflict", storedHash: entry.inputHash };
|
|
35
|
+
}
|
|
36
|
+
if (entry.responseJson === PENDING_RESPONSE_JSON) {
|
|
37
|
+
return { kind: "pending" };
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return { kind: "replay", response: JSON.parse(entry.responseJson) };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new CliError("internal_error", "Stored idempotency response is corrupt.");
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
reserve(idempotencyKey, commandName, inputHash) {
|
|
48
|
+
return this.withLock(() => {
|
|
49
|
+
const data = this.loadDataUnlocked();
|
|
50
|
+
const key = compositeKey(idempotencyKey, commandName);
|
|
51
|
+
if (!data[key]) {
|
|
52
|
+
data[key] = {
|
|
53
|
+
inputHash,
|
|
54
|
+
responseJson: PENDING_RESPONSE_JSON,
|
|
55
|
+
createdAt: Date.now(),
|
|
56
|
+
};
|
|
57
|
+
this.saveDataUnlocked(data);
|
|
58
|
+
return { kind: "execute" };
|
|
59
|
+
}
|
|
60
|
+
const entry = data[key];
|
|
61
|
+
if (entry.inputHash !== inputHash) {
|
|
62
|
+
return { kind: "conflict", storedHash: entry.inputHash };
|
|
63
|
+
}
|
|
64
|
+
if (entry.responseJson === PENDING_RESPONSE_JSON) {
|
|
65
|
+
return { kind: "pending" };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return { kind: "replay", response: JSON.parse(entry.responseJson) };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new CliError("internal_error", "Stored idempotency response is corrupt.");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
complete(idempotencyKey, commandName, inputHash, response) {
|
|
76
|
+
this.withLock(() => {
|
|
77
|
+
const data = this.loadDataUnlocked();
|
|
78
|
+
const key = compositeKey(idempotencyKey, commandName);
|
|
79
|
+
const entry = data[key];
|
|
80
|
+
if (!entry) {
|
|
81
|
+
data[key] = {
|
|
82
|
+
inputHash,
|
|
83
|
+
responseJson: JSON.stringify(response),
|
|
84
|
+
createdAt: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
this.saveDataUnlocked(data);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (entry.inputHash !== inputHash) {
|
|
90
|
+
throw new CliError("internal_error", "Failed to finalize idempotency record for mutation replay.");
|
|
91
|
+
}
|
|
92
|
+
entry.responseJson = JSON.stringify(response);
|
|
93
|
+
entry.createdAt = Date.now();
|
|
94
|
+
this.saveDataUnlocked(data);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
release(idempotencyKey, commandName, inputHash) {
|
|
98
|
+
this.withLock(() => {
|
|
99
|
+
const data = this.loadDataUnlocked();
|
|
100
|
+
const key = compositeKey(idempotencyKey, commandName);
|
|
101
|
+
const entry = data[key];
|
|
102
|
+
if (entry && entry.inputHash === inputHash && entry.responseJson === PENDING_RESPONSE_JSON) {
|
|
103
|
+
delete data[key];
|
|
104
|
+
this.saveDataUnlocked(data);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
withLock(fn) {
|
|
109
|
+
const lockFd = this.acquireLock();
|
|
31
110
|
try {
|
|
32
|
-
return
|
|
111
|
+
return fn();
|
|
33
112
|
}
|
|
34
|
-
|
|
35
|
-
|
|
113
|
+
finally {
|
|
114
|
+
this.releaseLock(lockFd);
|
|
36
115
|
}
|
|
37
116
|
}
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
117
|
+
acquireLock() {
|
|
118
|
+
const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
try {
|
|
121
|
+
const fd = openSync(this.lockPath, "wx");
|
|
122
|
+
writeFileSync(fd, `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, "utf-8");
|
|
123
|
+
return fd;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const code = error.code;
|
|
127
|
+
if (code !== "EEXIST") {
|
|
128
|
+
throw new CliError("internal_error", "Failed to acquire idempotency store lock.", {
|
|
129
|
+
details: error,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
this.tryClearStaleLock();
|
|
133
|
+
this.sleepMs(LOCK_POLL_INTERVAL_MS + Math.floor(Math.random() * 20));
|
|
134
|
+
}
|
|
49
135
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
136
|
+
throw new CliError("retryable_upstream", "Idempotency store is busy. Retry shortly.", {
|
|
137
|
+
retryable: true,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
releaseLock(lockFd) {
|
|
141
|
+
try {
|
|
142
|
+
closeSync(lockFd);
|
|
53
143
|
}
|
|
54
|
-
|
|
55
|
-
|
|
144
|
+
catch {
|
|
145
|
+
// best effort
|
|
56
146
|
}
|
|
57
147
|
try {
|
|
58
|
-
|
|
148
|
+
unlinkSync(this.lockPath);
|
|
59
149
|
}
|
|
60
150
|
catch {
|
|
61
|
-
|
|
151
|
+
// best effort
|
|
62
152
|
}
|
|
63
153
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
154
|
+
tryClearStaleLock() {
|
|
155
|
+
try {
|
|
156
|
+
const lockStat = statSync(this.lockPath);
|
|
157
|
+
const ageMs = Date.now() - lockStat.mtimeMs;
|
|
158
|
+
if (ageMs > LOCK_STALE_MS) {
|
|
159
|
+
unlinkSync(this.lockPath);
|
|
160
|
+
}
|
|
70
161
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.saveData(data);
|
|
74
|
-
}
|
|
75
|
-
release(idempotencyKey, commandName, inputHash) {
|
|
76
|
-
const data = this.loadData();
|
|
77
|
-
const key = compositeKey(idempotencyKey, commandName);
|
|
78
|
-
const entry = data[key];
|
|
79
|
-
if (entry && entry.inputHash === inputHash && entry.responseJson === PENDING_RESPONSE_JSON) {
|
|
80
|
-
delete data[key];
|
|
81
|
-
this.saveData(data);
|
|
162
|
+
catch {
|
|
163
|
+
// best effort
|
|
82
164
|
}
|
|
83
165
|
}
|
|
84
|
-
|
|
166
|
+
sleepMs(ms) {
|
|
167
|
+
Atomics.wait(SLEEP_ARRAY, 0, 0, ms);
|
|
168
|
+
}
|
|
169
|
+
loadDataUnlocked() {
|
|
85
170
|
let raw;
|
|
86
171
|
try {
|
|
87
172
|
raw = readFileSync(this.filePath, "utf-8");
|
|
@@ -94,6 +179,7 @@ export class IdempotencyStore {
|
|
|
94
179
|
data = JSON.parse(raw);
|
|
95
180
|
}
|
|
96
181
|
catch {
|
|
182
|
+
this.saveDataUnlocked({});
|
|
97
183
|
return {};
|
|
98
184
|
}
|
|
99
185
|
const now = Date.now();
|
|
@@ -105,12 +191,14 @@ export class IdempotencyStore {
|
|
|
105
191
|
}
|
|
106
192
|
}
|
|
107
193
|
if (pruned) {
|
|
108
|
-
this.
|
|
194
|
+
this.saveDataUnlocked(data);
|
|
109
195
|
}
|
|
110
196
|
return data;
|
|
111
197
|
}
|
|
112
|
-
|
|
113
|
-
|
|
198
|
+
saveDataUnlocked(data) {
|
|
199
|
+
const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
200
|
+
writeFileSync(tempPath, JSON.stringify(data), "utf-8");
|
|
201
|
+
renameSync(tempPath, this.filePath);
|
|
114
202
|
}
|
|
115
203
|
}
|
|
116
204
|
export function buildInternalIdempotencyKey(commandName, requestShape) {
|
package/dist/notion/markdown.js
CHANGED
|
@@ -265,6 +265,66 @@ function codeBlock(text, language) {
|
|
|
265
265
|
},
|
|
266
266
|
};
|
|
267
267
|
}
|
|
268
|
+
function imageBlock(url, caption) {
|
|
269
|
+
return {
|
|
270
|
+
object: "block",
|
|
271
|
+
type: "image",
|
|
272
|
+
image: {
|
|
273
|
+
type: "external",
|
|
274
|
+
external: { url },
|
|
275
|
+
caption: toRichText(caption),
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function parseMarkdownTable(lines, startIndex) {
|
|
280
|
+
const tableLines = [];
|
|
281
|
+
let i = startIndex;
|
|
282
|
+
while (i < lines.length && lines[i].trim().includes("|")) {
|
|
283
|
+
tableLines.push(lines[i].trim());
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
const linesConsumed = tableLines.length;
|
|
287
|
+
const parseCells = (line) => {
|
|
288
|
+
let stripped = line;
|
|
289
|
+
if (stripped.startsWith("|"))
|
|
290
|
+
stripped = stripped.slice(1);
|
|
291
|
+
if (stripped.endsWith("|"))
|
|
292
|
+
stripped = stripped.slice(0, -1);
|
|
293
|
+
return stripped.split("|").map((c) => c.trim());
|
|
294
|
+
};
|
|
295
|
+
const isSeparator = (line) => {
|
|
296
|
+
const cells = parseCells(line);
|
|
297
|
+
return cells.every((c) => /^[-:]+$/.test(c));
|
|
298
|
+
};
|
|
299
|
+
const hasColumnHeader = tableLines.length >= 2 && isSeparator(tableLines[1]);
|
|
300
|
+
const dataLines = tableLines.filter((_, idx) => !(hasColumnHeader && idx === 1));
|
|
301
|
+
const tableWidth = dataLines.length > 0 ? parseCells(dataLines[0]).length : 0;
|
|
302
|
+
const children = dataLines.map((line) => {
|
|
303
|
+
const cells = parseCells(line);
|
|
304
|
+
while (cells.length < tableWidth)
|
|
305
|
+
cells.push("");
|
|
306
|
+
return {
|
|
307
|
+
object: "block",
|
|
308
|
+
type: "table_row",
|
|
309
|
+
table_row: {
|
|
310
|
+
cells: cells.slice(0, tableWidth).map((c) => toRichText(c)),
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
return {
|
|
315
|
+
block: {
|
|
316
|
+
object: "block",
|
|
317
|
+
type: "table",
|
|
318
|
+
table: {
|
|
319
|
+
table_width: tableWidth,
|
|
320
|
+
has_column_header: hasColumnHeader,
|
|
321
|
+
has_row_header: false,
|
|
322
|
+
children,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
linesConsumed,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
268
328
|
function isDivider(trimmed) {
|
|
269
329
|
return trimmed === "---" || trimmed === "***" || trimmed === "___";
|
|
270
330
|
}
|
|
@@ -312,6 +372,20 @@ export function markdownToBlocks(markdown) {
|
|
|
312
372
|
index += 1;
|
|
313
373
|
continue;
|
|
314
374
|
}
|
|
375
|
+
const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
|
|
376
|
+
if (imageMatch) {
|
|
377
|
+
flushParagraph();
|
|
378
|
+
blocks.push(imageBlock(imageMatch[2], imageMatch[1]));
|
|
379
|
+
index += 1;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (trimmed.startsWith("|")) {
|
|
383
|
+
flushParagraph();
|
|
384
|
+
const result = parseMarkdownTable(lines, index);
|
|
385
|
+
blocks.push(result.block);
|
|
386
|
+
index += result.linesConsumed;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
315
389
|
const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)$/);
|
|
316
390
|
if (headingMatch) {
|
|
317
391
|
flushParagraph();
|
|
@@ -140,3 +140,7 @@ export declare function replaceBlockRange(notion: NotionClientAdapter, args: {
|
|
|
140
140
|
dryRun: boolean;
|
|
141
141
|
maxBlocks: number;
|
|
142
142
|
}): Promise<Record<string, unknown>>;
|
|
143
|
+
export declare function deleteBlocks(notion: NotionClientAdapter, args: {
|
|
144
|
+
blockIds: string[];
|
|
145
|
+
}): Promise<Record<string, unknown>>;
|
|
146
|
+
export declare function areBlocksDeleted(notion: NotionClientAdapter, blockIds: string[]): Promise<boolean>;
|
|
@@ -107,6 +107,94 @@ function readRelationProperty(page, propertyName) {
|
|
|
107
107
|
.map((item) => item.id)
|
|
108
108
|
.filter((value) => typeof value === "string" && value.length > 0);
|
|
109
109
|
}
|
|
110
|
+
function collectFilterPropertyRefs(value, refs) {
|
|
111
|
+
if (!value || typeof value !== "object") {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
for (const item of value) {
|
|
116
|
+
collectFilterPropertyRefs(item, refs);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const record = value;
|
|
121
|
+
if (typeof record.property === "string" && record.property.length > 0) {
|
|
122
|
+
refs.add(record.property);
|
|
123
|
+
}
|
|
124
|
+
for (const child of Object.values(record)) {
|
|
125
|
+
collectFilterPropertyRefs(child, refs);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function levenshteinDistance(a, b) {
|
|
129
|
+
const left = a.toLowerCase();
|
|
130
|
+
const right = b.toLowerCase();
|
|
131
|
+
if (left === right) {
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
const matrix = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
|
|
135
|
+
for (let i = 0; i <= left.length; i += 1) {
|
|
136
|
+
matrix[i][0] = i;
|
|
137
|
+
}
|
|
138
|
+
for (let j = 0; j <= right.length; j += 1) {
|
|
139
|
+
matrix[0][j] = j;
|
|
140
|
+
}
|
|
141
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
142
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
143
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
144
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return matrix[left.length][right.length];
|
|
148
|
+
}
|
|
149
|
+
function suggestPropertyNames(raw, candidates) {
|
|
150
|
+
const target = raw.toLowerCase();
|
|
151
|
+
return [...candidates]
|
|
152
|
+
.sort((a, b) => {
|
|
153
|
+
const aLower = a.toLowerCase();
|
|
154
|
+
const bLower = b.toLowerCase();
|
|
155
|
+
const aStarts = aLower.startsWith(target) ? 0 : 1;
|
|
156
|
+
const bStarts = bLower.startsWith(target) ? 0 : 1;
|
|
157
|
+
if (aStarts !== bStarts) {
|
|
158
|
+
return aStarts - bStarts;
|
|
159
|
+
}
|
|
160
|
+
const aContains = aLower.includes(target) ? 0 : 1;
|
|
161
|
+
const bContains = bLower.includes(target) ? 0 : 1;
|
|
162
|
+
if (aContains !== bContains) {
|
|
163
|
+
return aContains - bContains;
|
|
164
|
+
}
|
|
165
|
+
const distanceDiff = levenshteinDistance(raw, a) - levenshteinDistance(raw, b);
|
|
166
|
+
if (distanceDiff !== 0) {
|
|
167
|
+
return distanceDiff;
|
|
168
|
+
}
|
|
169
|
+
return a.localeCompare(b);
|
|
170
|
+
})
|
|
171
|
+
.slice(0, 3);
|
|
172
|
+
}
|
|
173
|
+
async function validateDataSourceFilterProperties(ctx, dataSourceId, filter) {
|
|
174
|
+
if (!filter) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const refs = new Set();
|
|
178
|
+
collectFilterPropertyRefs(filter, refs);
|
|
179
|
+
if (refs.size === 0) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const schema = await hydrateDataSourceSchema(ctx, dataSourceId);
|
|
183
|
+
const validNames = Object.keys(schema);
|
|
184
|
+
const validIds = new Set(Object.values(schema)
|
|
185
|
+
.map((entry) => entry.id)
|
|
186
|
+
.filter((value) => typeof value === "string" && value.length > 0));
|
|
187
|
+
for (const property of refs) {
|
|
188
|
+
if (schema[property] || validIds.has(property)) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const suggestions = suggestPropertyNames(property, validNames);
|
|
192
|
+
const suggestionText = suggestions.length > 0
|
|
193
|
+
? ` Did you mean ${suggestions.map((name) => `"${name}"`).join(", ")}?`
|
|
194
|
+
: "";
|
|
195
|
+
throw new CliError("invalid_input", `Unknown filter property "${property}" for this data source.${suggestionText}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
110
198
|
async function withBestEffortPageMutation(ctx, pageId, apply) {
|
|
111
199
|
const attempts = 2;
|
|
112
200
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
@@ -288,6 +376,7 @@ export async function getDataSourceSchema(notion, dataSourceId) {
|
|
|
288
376
|
};
|
|
289
377
|
}
|
|
290
378
|
export async function queryDataSourcePages(ctx, input) {
|
|
379
|
+
await validateDataSourceFilterProperties(ctx, input.dataSourceId, input.filter);
|
|
291
380
|
const payload = {
|
|
292
381
|
data_source_id: input.dataSourceId,
|
|
293
382
|
page_size: Math.min(Math.max(1, input.limit), 100),
|
|
@@ -455,6 +544,54 @@ function extractBlockText(block) {
|
|
|
455
544
|
})
|
|
456
545
|
.join("");
|
|
457
546
|
}
|
|
547
|
+
function richTextToMarkdown(richTextArray) {
|
|
548
|
+
return richTextArray
|
|
549
|
+
.map((item) => {
|
|
550
|
+
if (!item || typeof item !== "object")
|
|
551
|
+
return "";
|
|
552
|
+
const plain = item.plain_text;
|
|
553
|
+
if (typeof plain !== "string")
|
|
554
|
+
return "";
|
|
555
|
+
const annotations = item.annotations;
|
|
556
|
+
const href = item.href ??
|
|
557
|
+
(item.text?.link?.url ?? null);
|
|
558
|
+
let result = plain;
|
|
559
|
+
if (annotations?.code) {
|
|
560
|
+
result = `\`${result}\``;
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
if (annotations?.bold && annotations?.italic) {
|
|
564
|
+
result = `***${result}***`;
|
|
565
|
+
}
|
|
566
|
+
else if (annotations?.bold) {
|
|
567
|
+
result = `**${result}**`;
|
|
568
|
+
}
|
|
569
|
+
else if (annotations?.italic) {
|
|
570
|
+
result = `*${result}*`;
|
|
571
|
+
}
|
|
572
|
+
if (annotations?.strikethrough) {
|
|
573
|
+
result = `~~${result}~~`;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (typeof href === "string") {
|
|
577
|
+
result = `[${result}](${href})`;
|
|
578
|
+
}
|
|
579
|
+
return result;
|
|
580
|
+
})
|
|
581
|
+
.join("");
|
|
582
|
+
}
|
|
583
|
+
function extractBlockMarkdown(block) {
|
|
584
|
+
const type = block.type;
|
|
585
|
+
if (typeof type !== "string")
|
|
586
|
+
return null;
|
|
587
|
+
const typedData = block[type];
|
|
588
|
+
if (!typedData || typeof typedData !== "object")
|
|
589
|
+
return null;
|
|
590
|
+
const richText = typedData.rich_text;
|
|
591
|
+
if (!Array.isArray(richText))
|
|
592
|
+
return null;
|
|
593
|
+
return richTextToMarkdown(richText);
|
|
594
|
+
}
|
|
458
595
|
function toCompactBlock(block) {
|
|
459
596
|
return {
|
|
460
597
|
id: block.id ?? null,
|
|
@@ -508,7 +645,7 @@ function renderQuoteMarkdown(text, indent) {
|
|
|
508
645
|
}
|
|
509
646
|
function renderBlockToMarkdown(block, depth) {
|
|
510
647
|
const type = typeof block.type === "string" ? block.type : "unsupported";
|
|
511
|
-
const text =
|
|
648
|
+
const text = extractBlockMarkdown(block) ?? "";
|
|
512
649
|
const indent = " ".repeat(depth);
|
|
513
650
|
const children = collectRenderableChildren(block);
|
|
514
651
|
const childMarkdown = children.length > 0 ? renderBlocksToMarkdown(children, depth + 1) : "";
|
|
@@ -557,6 +694,41 @@ function renderBlockToMarkdown(block, depth) {
|
|
|
557
694
|
}
|
|
558
695
|
case "toggle":
|
|
559
696
|
return withChildren(`${indent}- ${text}`, true);
|
|
697
|
+
case "image": {
|
|
698
|
+
const img = block.image;
|
|
699
|
+
let imgUrl = "";
|
|
700
|
+
if (img) {
|
|
701
|
+
if (img.type === "external") {
|
|
702
|
+
const ext = img.external;
|
|
703
|
+
imgUrl = ext?.url ?? "";
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
const file = img.file;
|
|
707
|
+
imgUrl = file?.url ?? "";
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const caption = img && Array.isArray(img.caption) ? richTextToMarkdown(img.caption) : "";
|
|
711
|
+
return withChildren(`${indent}`);
|
|
712
|
+
}
|
|
713
|
+
case "table": {
|
|
714
|
+
const tbl = block.table;
|
|
715
|
+
const rows = children;
|
|
716
|
+
const rowLines = [];
|
|
717
|
+
for (let ri = 0; ri < rows.length; ri++) {
|
|
718
|
+
const row = rows[ri];
|
|
719
|
+
const tr = row.table_row;
|
|
720
|
+
if (!tr?.cells)
|
|
721
|
+
continue;
|
|
722
|
+
const cellTexts = tr.cells.map((cell) => richTextToMarkdown(cell).replace(/\|/g, "\\|"));
|
|
723
|
+
rowLines.push(`${indent}| ${cellTexts.join(" | ")} |`);
|
|
724
|
+
if (ri === 0 && tbl?.has_column_header) {
|
|
725
|
+
rowLines.push(`${indent}| ${cellTexts.map(() => "---").join(" | ")} |`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return rowLines.join("\n");
|
|
729
|
+
}
|
|
730
|
+
case "table_row":
|
|
731
|
+
return "";
|
|
560
732
|
default: {
|
|
561
733
|
const fallback = text.length > 0 ? text : `[${type}]`;
|
|
562
734
|
return withChildren(`${indent}${fallback}`);
|
|
@@ -996,3 +1168,32 @@ export async function replaceBlockRange(notion, args) {
|
|
|
996
1168
|
deleted_count: blocksToDelete.length,
|
|
997
1169
|
};
|
|
998
1170
|
}
|
|
1171
|
+
export async function deleteBlocks(notion, args) {
|
|
1172
|
+
const deletedIds = [];
|
|
1173
|
+
for (const blockId of args.blockIds) {
|
|
1174
|
+
await notion.deleteBlock({ block_id: blockId });
|
|
1175
|
+
deletedIds.push(blockId);
|
|
1176
|
+
}
|
|
1177
|
+
return {
|
|
1178
|
+
deleted_count: deletedIds.length,
|
|
1179
|
+
deleted_ids: deletedIds,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
export async function areBlocksDeleted(notion, blockIds) {
|
|
1183
|
+
for (const blockId of blockIds) {
|
|
1184
|
+
try {
|
|
1185
|
+
const block = asRecord(await notion.retrieveBlock(blockId));
|
|
1186
|
+
if (block.archived !== true) {
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
catch (error) {
|
|
1191
|
+
const status = getStatus(error);
|
|
1192
|
+
if (status === 404) {
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
throw error;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|