@eightstate/escli 0.5.0 → 0.7.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/dist/commands/notion/block/trash.js +71 -0
- package/dist/commands/notion/comments/add.js +48 -0
- package/dist/commands/notion/comments/get.js +42 -0
- package/dist/commands/notion/comments/list.js +55 -0
- package/dist/commands/notion/comments/reply.js +45 -0
- package/dist/commands/notion/comments/thread.js +87 -0
- package/dist/commands/notion/db/create.js +555 -0
- package/dist/commands/notion/db/query.js +451 -0
- package/dist/commands/notion/db/row/create.js +74 -0
- package/dist/commands/notion/db/row/update.js +165 -0
- package/dist/commands/notion/ds/create.js +73 -0
- package/dist/commands/notion/enroll.js +302 -0
- package/dist/commands/notion/index.js +24 -0
- package/dist/commands/notion/page/edit.js +73 -0
- package/dist/commands/notion/page/move.js +59 -0
- package/dist/commands/notion/page/read.js +60 -0
- package/dist/commands/notion/page/replace-content.js +80 -0
- package/dist/commands/notion/page/replace-text.js +80 -0
- package/dist/commands/notion/page/replace.js +63 -0
- package/dist/commands/notion/page/trash.js +79 -0
- package/dist/commands/notion/search.js +207 -0
- package/dist/commands/notion/upload/attach.js +105 -0
- package/dist/commands/notion/upload/index.js +129 -0
- package/dist/commands/notion/upload/list.js +78 -0
- package/dist/commands/notion/upload/status.js +76 -0
- package/dist/commands/notion/view/create.js +78 -0
- package/dist/commands/notion/whoami.js +96 -0
- package/dist/commands/research.js +11 -0
- package/dist/entry.js +12 -6
- package/dist/io/render-kv.js +12 -0
- package/dist/io/render-labeled.js +16 -0
- package/dist/io/render-table.js +19 -0
- package/dist/io/render-trailer.js +7 -0
- package/dist/lib/manifest.js +1 -1
- package/dist/lib/notion/comments/shared.js +366 -0
- package/dist/lib/notion/db-row/common.js +367 -0
- package/dist/lib/notion/manifest-pass.js +4 -0
- package/dist/lib/notion/page/content-common.js +473 -0
- package/dist/lib/notion/trash-move/support.js +300 -0
- package/dist/lib/notion/upload/shared.js +372 -0
- package/dist/lib/registry.js +118 -25
- package/dist/services/notion.js +274 -0
- package/dist/services/research.js +31 -10
- package/oclif.manifest.json +4084 -1
- package/package.json +22 -17
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { Errors } from '@oclif/core';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { ErrorCode } from '@eightstate/contracts/errors';
|
|
5
|
+
import { ExitCodes } from '@eightstate/contracts/exit-codes';
|
|
6
|
+
import { BaseCommand } from '../../../base-command.js';
|
|
7
|
+
import { EscliError } from '../../escli-error.js';
|
|
8
|
+
import { writeStderr, writeStdout } from '../../../io/io.js';
|
|
9
|
+
import { renderKv } from '../../../io/render-kv.js';
|
|
10
|
+
import { NOTION_VERSION } from '../../../services/notion.js';
|
|
11
|
+
import { hasManifestFlag } from '../manifest-pass.js';
|
|
12
|
+
export class NotionWriteCommand extends BaseCommand {
|
|
13
|
+
outputFlags = {};
|
|
14
|
+
rawBodies = [];
|
|
15
|
+
rememberResult(result) {
|
|
16
|
+
this.rawBodies.push(result.raw);
|
|
17
|
+
return result.data;
|
|
18
|
+
}
|
|
19
|
+
async run() {
|
|
20
|
+
if (hasManifestFlag(this.argv)) {
|
|
21
|
+
await super.run();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const data = await this.execute();
|
|
26
|
+
if (this.outputFlags.raw) {
|
|
27
|
+
await writeStdout(`${JSON.stringify(this.rawBodies.length > 1 ? this.rawBodies : this.rawBodies[0] ?? data)}\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (this.outputFlags.json) {
|
|
31
|
+
await writeStdout(`${JSON.stringify(successEnvelope(data))}\n`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await writeStdout(`${this.render(data)}\n`);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const escliError = toEscliError(error);
|
|
38
|
+
// Use the EscliError's exit code directly — derived from
|
|
39
|
+
// contracts/failure-taxonomy which is the single source of truth for
|
|
40
|
+
// notion.* → exit code mapping. No surface-specific override.
|
|
41
|
+
process.exitCode = escliError.exitCode;
|
|
42
|
+
if (this.outputFlags.json || argvHasJson(this.argv))
|
|
43
|
+
await writeStdout(`${JSON.stringify(errorEnvelope(escliError))}\n`);
|
|
44
|
+
// In --json mode, the JSON envelope on stdout is the contract; the
|
|
45
|
+
// human-readable text on stderr is supplementary. Suppress on --json
|
|
46
|
+
// so automation parses a single stable surface.
|
|
47
|
+
if (!(this.outputFlags.json || argvHasJson(this.argv)))
|
|
48
|
+
await writeStderr(`${actionableMessage(escliError)}\n`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function notionErrors() {
|
|
53
|
+
return [
|
|
54
|
+
ErrorCode.UsageRequired,
|
|
55
|
+
ErrorCode.UsageInvalid,
|
|
56
|
+
ErrorCode.UsageUnknownFlag,
|
|
57
|
+
ErrorCode.ValidationFailed,
|
|
58
|
+
ErrorCode.FileReadFailed,
|
|
59
|
+
ErrorCode.AuthRequired,
|
|
60
|
+
ErrorCode.NotionUnauthorized,
|
|
61
|
+
ErrorCode.NotionEnrollmentRequired,
|
|
62
|
+
ErrorCode.NotionRestricted,
|
|
63
|
+
ErrorCode.NotionNotFound,
|
|
64
|
+
ErrorCode.NotionValidation,
|
|
65
|
+
ErrorCode.NotionConflict,
|
|
66
|
+
ErrorCode.NotionRateLimited,
|
|
67
|
+
ErrorCode.NotionTimeoutUncertain,
|
|
68
|
+
ErrorCode.GateInvalidResponse,
|
|
69
|
+
ErrorCode.NetworkError,
|
|
70
|
+
ErrorCode.NetworkTimeout,
|
|
71
|
+
ErrorCode.ServiceUnavailable,
|
|
72
|
+
ErrorCode.ApiError,
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
export function renderReceipt(rows) {
|
|
76
|
+
return renderKv(rows);
|
|
77
|
+
}
|
|
78
|
+
export function requireYes(yes) {
|
|
79
|
+
if (yes)
|
|
80
|
+
return;
|
|
81
|
+
if (!process.stdin.isTTY)
|
|
82
|
+
throw new EscliError('refusing destructive write in non-interactive mode; re-run with --yes', { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
83
|
+
throw new EscliError('interactive confirmation is required; re-run with --yes after reviewing the command', { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
84
|
+
}
|
|
85
|
+
export function parseNotionId(value) {
|
|
86
|
+
const decoded = decodeURIComponent(value);
|
|
87
|
+
const uuid = decoded.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/iu)?.[0];
|
|
88
|
+
if (uuid)
|
|
89
|
+
return uuid.toLowerCase();
|
|
90
|
+
const compact = decoded.match(/[0-9a-f]{32}/iu)?.[0];
|
|
91
|
+
if (!compact)
|
|
92
|
+
return value;
|
|
93
|
+
return `${compact.slice(0, 8)}-${compact.slice(8, 12)}-${compact.slice(12, 16)}-${compact.slice(16, 20)}-${compact.slice(20)}`.toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
export function recordValue(value) {
|
|
96
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
97
|
+
}
|
|
98
|
+
export function stringValue(value) {
|
|
99
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
100
|
+
}
|
|
101
|
+
export function numberValue(value) {
|
|
102
|
+
const number = Number(value);
|
|
103
|
+
return Number.isFinite(number) ? number : undefined;
|
|
104
|
+
}
|
|
105
|
+
export function boolValue(value) {
|
|
106
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
107
|
+
}
|
|
108
|
+
export function pageRef(raw, fallbackId) {
|
|
109
|
+
const record = recordValue(raw);
|
|
110
|
+
return { id: stringValue(record?.id) ?? fallbackId, title: titleFromPage(raw) ?? 'Untitled' };
|
|
111
|
+
}
|
|
112
|
+
export function blockRef(raw, fallbackId) {
|
|
113
|
+
const record = recordValue(raw);
|
|
114
|
+
const type = stringValue(record?.type) ?? 'block';
|
|
115
|
+
return { id: stringValue(record?.id) ?? fallbackId, type, preview: blockPreview(record, type) };
|
|
116
|
+
}
|
|
117
|
+
export function parentRef(value) {
|
|
118
|
+
const parent = recordValue(value);
|
|
119
|
+
if (!parent)
|
|
120
|
+
return undefined;
|
|
121
|
+
const type = stringValue(parent.type) ?? 'unknown';
|
|
122
|
+
return {
|
|
123
|
+
type,
|
|
124
|
+
page_id: stringValue(parent.page_id),
|
|
125
|
+
data_source_id: stringValue(parent.data_source_id),
|
|
126
|
+
database_id: stringValue(parent.database_id),
|
|
127
|
+
block_id: stringValue(parent.block_id),
|
|
128
|
+
title: stringValue(parent.title) ?? stringValue(parent.name),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function formatParent(parent) {
|
|
132
|
+
if (!parent)
|
|
133
|
+
return 'unknown';
|
|
134
|
+
const id = parent.page_id ?? parent.data_source_id ?? parent.database_id ?? parent.block_id ?? '';
|
|
135
|
+
const label = parent.type.replace(/_id$/u, '');
|
|
136
|
+
return `${label}${parent.title ? ` ${parent.title}` : ''}${id ? ` (${id})` : ''}`;
|
|
137
|
+
}
|
|
138
|
+
export function formatParentCompact(parent) {
|
|
139
|
+
if (!parent)
|
|
140
|
+
return 'unknown';
|
|
141
|
+
const id = parent.page_id ?? parent.data_source_id ?? parent.database_id ?? parent.block_id ?? '';
|
|
142
|
+
return `${parent.type}${id ? ` ${id}` : ''}`;
|
|
143
|
+
}
|
|
144
|
+
export function botName(raw) {
|
|
145
|
+
const record = recordValue(raw);
|
|
146
|
+
return stringValue(record?.bot_name) ?? stringValue(recordValue(record?.bot)?.name) ?? stringValue(recordValue(record?.last_edited_by)?.name) ?? '';
|
|
147
|
+
}
|
|
148
|
+
export function markdownPreview(value, max = 160) {
|
|
149
|
+
const markdown = stringValue(value) ?? '';
|
|
150
|
+
if (markdown.length <= max)
|
|
151
|
+
return markdown;
|
|
152
|
+
return `${markdown.slice(0, max).trimEnd()}...`;
|
|
153
|
+
}
|
|
154
|
+
export function estimateText(value) {
|
|
155
|
+
return { chars: value.length, lines: value.length === 0 ? 0 : value.split(/\r?\n/u).length };
|
|
156
|
+
}
|
|
157
|
+
export function formatEstimate(value) {
|
|
158
|
+
return `${value.chars.toLocaleString('en-US')} chars / ${value.lines.toLocaleString('en-US')} lines`;
|
|
159
|
+
}
|
|
160
|
+
export function deltaEstimate(before, after) {
|
|
161
|
+
return { chars: after.chars - before.chars, lines: after.lines - before.lines };
|
|
162
|
+
}
|
|
163
|
+
export async function readMarkdownInput(file, text) {
|
|
164
|
+
if ((file && text !== undefined) || (!file && text === undefined)) {
|
|
165
|
+
throw new EscliError('usage: choose exactly one content source: --file or --text', { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
166
|
+
}
|
|
167
|
+
if (text !== undefined)
|
|
168
|
+
return { source: 'text', markdown: text };
|
|
169
|
+
try {
|
|
170
|
+
const path = file;
|
|
171
|
+
return { source: `file:${basename(path)}`, markdown: await readFile(path, 'utf8') };
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw new EscliError(`failed to read markdown file: ${error instanceof Error ? error.message : String(error)}`, { code: ErrorCode.FileReadFailed, exitCode: ExitCodes.Usage, details: { file } });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function exactOneDestination(toPage, toDataSource) {
|
|
178
|
+
if ((toPage && toDataSource) || (!toPage && !toDataSource)) {
|
|
179
|
+
throw new EscliError('usage: choose exactly one destination: --to-page or --to-data-source', { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
180
|
+
}
|
|
181
|
+
if (toPage)
|
|
182
|
+
return { type: 'page_id', page_id: parseNotionId(toPage) };
|
|
183
|
+
return { type: 'data_source_id', data_source_id: parseNotionId(toDataSource) };
|
|
184
|
+
}
|
|
185
|
+
export function titleFromPage(raw) {
|
|
186
|
+
const record = recordValue(raw);
|
|
187
|
+
const direct = stringValue(record?.title) ?? stringValue(record?.name);
|
|
188
|
+
if (direct)
|
|
189
|
+
return direct;
|
|
190
|
+
const properties = recordValue(record?.properties);
|
|
191
|
+
for (const property of Object.values(properties ?? {})) {
|
|
192
|
+
const propertyRecord = recordValue(property);
|
|
193
|
+
if (propertyRecord?.type === 'title' && Array.isArray(propertyRecord.title)) {
|
|
194
|
+
const title = propertyRecord.title.map((part) => stringValue(recordValue(part)?.plain_text) ?? stringValue(recordValue(recordValue(part)?.text)?.content) ?? '').join('').trim();
|
|
195
|
+
if (title)
|
|
196
|
+
return title;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
export function unknownBlockIds(raw) {
|
|
202
|
+
const ids = recordValue(raw)?.unknown_block_ids;
|
|
203
|
+
return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
|
|
204
|
+
}
|
|
205
|
+
function blockPreview(record, type) {
|
|
206
|
+
const direct = stringValue(record?.preview);
|
|
207
|
+
if (direct)
|
|
208
|
+
return direct;
|
|
209
|
+
const typed = recordValue(record?.[type]);
|
|
210
|
+
const richText = typed?.rich_text;
|
|
211
|
+
if (!Array.isArray(richText))
|
|
212
|
+
return '';
|
|
213
|
+
return richText.map((part) => stringValue(recordValue(part)?.plain_text) ?? '').join('').trim();
|
|
214
|
+
}
|
|
215
|
+
function successEnvelope(data) {
|
|
216
|
+
return { ok: true, data, error: null, meta: { next: null, notion_version: NOTION_VERSION } };
|
|
217
|
+
}
|
|
218
|
+
function errorEnvelope(error) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
data: null,
|
|
222
|
+
error: {
|
|
223
|
+
code: error.code,
|
|
224
|
+
message: error.message,
|
|
225
|
+
data: error.details,
|
|
226
|
+
cause_code: error.causeCode,
|
|
227
|
+
},
|
|
228
|
+
meta: { next: null, notion_version: NOTION_VERSION },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function toEscliError(error) {
|
|
232
|
+
if (error instanceof EscliError)
|
|
233
|
+
return error;
|
|
234
|
+
if (error instanceof Errors.CLIError)
|
|
235
|
+
return new EscliError(cleanOclifMessage(error.message), { code: oclifCode(error), exitCode: ExitCodes.Usage, details: { oclif_exit: error.oclif?.exit } });
|
|
236
|
+
if (error instanceof Error)
|
|
237
|
+
return new EscliError(error.message, { code: ErrorCode.Internal });
|
|
238
|
+
return new EscliError('unknown error', { code: ErrorCode.Unknown, details: error });
|
|
239
|
+
}
|
|
240
|
+
function oclifCode(error) {
|
|
241
|
+
const name = error.constructor.name;
|
|
242
|
+
const message = error.message;
|
|
243
|
+
if (name === 'NonExistentFlagsError' || message.startsWith('Nonexistent flag') || message.startsWith('Unexpected flag'))
|
|
244
|
+
return ErrorCode.UsageUnknownFlag;
|
|
245
|
+
if (name === 'RequiredArgsError' || message.startsWith('Missing ') || message.includes('expects a value'))
|
|
246
|
+
return ErrorCode.UsageRequired;
|
|
247
|
+
return ErrorCode.UsageInvalid;
|
|
248
|
+
}
|
|
249
|
+
function cleanOclifMessage(message) {
|
|
250
|
+
return message.replace(/\nSee more help with --help$/u, '');
|
|
251
|
+
}
|
|
252
|
+
function actionableMessage(error) {
|
|
253
|
+
const cause = error.causeCode ?? '';
|
|
254
|
+
const message = error.message.toLowerCase();
|
|
255
|
+
if (error.message.startsWith('usage: choose exactly one destination'))
|
|
256
|
+
return error.message;
|
|
257
|
+
if (error.message.startsWith('refusing destructive write'))
|
|
258
|
+
return error.message;
|
|
259
|
+
if (error.message.startsWith('interactive confirmation'))
|
|
260
|
+
return error.message;
|
|
261
|
+
if (error.code === ErrorCode.NotionUnauthorized)
|
|
262
|
+
return 'Notion token is invalid or expired; run escli notion enroll';
|
|
263
|
+
if (error.code === ErrorCode.NotionEnrollmentRequired || error.code === ErrorCode.AuthRequired)
|
|
264
|
+
return 'No Notion bot token enrolled for this Clerk user; run escli notion enroll';
|
|
265
|
+
if (error.code === ErrorCode.NotionRestricted)
|
|
266
|
+
return 'Bot lacks access/capability. Share source and destination with the bot and grant update content.';
|
|
267
|
+
if (error.code === ErrorCode.NotionNotFound)
|
|
268
|
+
return 'Object not found or not shared with bot; verify ID and share target with integration';
|
|
269
|
+
if (error.code === ErrorCode.NotionRateLimited)
|
|
270
|
+
return `Notion rate limited this integration${retrySeconds(error.details)}; retry later`;
|
|
271
|
+
if (error.code === ErrorCode.NotionConflict)
|
|
272
|
+
return 'Notion conflict while writing; refetch current state before retrying';
|
|
273
|
+
if (error.code === ErrorCode.NotionValidation && (message.includes('multiple') || message.includes('more than one')))
|
|
274
|
+
return 'old text matched multiple places; re-run with --replace-all only if all matches should change';
|
|
275
|
+
if (error.code === ErrorCode.NotionValidation && (message.includes('not found') || message.includes('no match')))
|
|
276
|
+
return 'old text was not found; fetch page markdown and retry with exact text';
|
|
277
|
+
if (error.code === ErrorCode.NotionValidation && (message.includes('child') || message.includes('delete')))
|
|
278
|
+
return 'edit would delete child pages/databases; review affected_items and re-run with --allow-deleting-content --yes';
|
|
279
|
+
if (error.code === ErrorCode.NotionValidation && (message.includes('database') || cause.includes('database')))
|
|
280
|
+
return 'use --to-data-source <data_source_id>, not database_id; retrieve database to list data sources';
|
|
281
|
+
if (error.code === ErrorCode.NotionValidation && (message.includes('block') || message.includes('regular page')))
|
|
282
|
+
return 'destination for --to-page must be a regular page, not another block type';
|
|
283
|
+
if (error.code === ErrorCode.NotionValidation)
|
|
284
|
+
return `Notion rejected request shape: ${error.message}; check command flags`;
|
|
285
|
+
if (error.code === ErrorCode.ServiceUnavailable && (cause === 'gateway_timeout' || message.includes('timeout')))
|
|
286
|
+
return 'Notion timed out; refetch before retrying destructive writes';
|
|
287
|
+
if (error.code === ErrorCode.ServiceUnavailable)
|
|
288
|
+
return 'Notion unavailable; retry later';
|
|
289
|
+
if (error.code === ErrorCode.GateInvalidResponse)
|
|
290
|
+
return 'internal request serialization failed; report broker bug with request id if present';
|
|
291
|
+
return error.message;
|
|
292
|
+
}
|
|
293
|
+
function retrySeconds(details) {
|
|
294
|
+
const retryAfterMs = numberValue(recordValue(details)?.retry_after_ms);
|
|
295
|
+
return retryAfterMs === undefined ? '' : `; retry after ${Math.ceil(retryAfterMs / 1000)}s`;
|
|
296
|
+
}
|
|
297
|
+
function argvHasJson(argv) {
|
|
298
|
+
return argv.includes('--json');
|
|
299
|
+
}
|
|
300
|
+
//# sourceMappingURL=support.js.map
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { Flags } from '@oclif/core';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { ErrorCode } from '@eightstate/contracts/errors';
|
|
6
|
+
import { ExitCodes } from '@eightstate/contracts/exit-codes';
|
|
7
|
+
import { renderKv } from '../../../io/render-kv.js';
|
|
8
|
+
import { writeStderr, writeStdout } from '../../../io/io.js';
|
|
9
|
+
import { buildUsageError } from '../../../lib/envelope.js';
|
|
10
|
+
import { EscliError } from '../../../lib/escli-error.js';
|
|
11
|
+
import { NOTION_VERSION, notionBlocksAppendChildren, notionPagesPatchProps } from '../../../services/notion.js';
|
|
12
|
+
export const fileUploadErrors = [
|
|
13
|
+
ErrorCode.UsageRequired,
|
|
14
|
+
ErrorCode.UsageInvalid,
|
|
15
|
+
ErrorCode.UsageUnknownFlag,
|
|
16
|
+
ErrorCode.ValidationFailed,
|
|
17
|
+
ErrorCode.FileNotFound,
|
|
18
|
+
ErrorCode.FileReadFailed,
|
|
19
|
+
ErrorCode.AuthRequired,
|
|
20
|
+
ErrorCode.GateCredentialUnavailable,
|
|
21
|
+
ErrorCode.GateInvalidResponse,
|
|
22
|
+
ErrorCode.NetworkError,
|
|
23
|
+
ErrorCode.NetworkTimeout,
|
|
24
|
+
ErrorCode.ServiceUnavailable,
|
|
25
|
+
ErrorCode.ApiError,
|
|
26
|
+
ErrorCode.NotionUnauthorized,
|
|
27
|
+
ErrorCode.NotionRestricted,
|
|
28
|
+
ErrorCode.NotionNotFound,
|
|
29
|
+
ErrorCode.NotionRateLimited,
|
|
30
|
+
ErrorCode.NotionValidation,
|
|
31
|
+
ErrorCode.NotionConflict,
|
|
32
|
+
ErrorCode.NotionTimeoutUncertain,
|
|
33
|
+
ErrorCode.NotionEnrollmentRequired,
|
|
34
|
+
ErrorCode.NotionBatchPartial,
|
|
35
|
+
];
|
|
36
|
+
export const rawFlag = Flags.boolean({ description: 'Emit verbatim Notion response body instead of shaped output.' });
|
|
37
|
+
export const AttachTargetSchema = z.discriminatedUnion('kind', [
|
|
38
|
+
z.object({ kind: z.literal('none') }),
|
|
39
|
+
z.object({ kind: z.literal('block'), parent: z.string(), as: z.enum(['file', 'image', 'pdf', 'video', 'audio']), caption: z.string().optional() }),
|
|
40
|
+
z.object({ kind: z.literal('property'), page: z.string(), property: z.string(), replace_all: z.boolean() }),
|
|
41
|
+
z.object({ kind: z.literal('page'), page: z.string(), slot: z.enum(['cover', 'icon']) }),
|
|
42
|
+
]);
|
|
43
|
+
export const UploadedFileSchema = z.object({
|
|
44
|
+
name: z.string(),
|
|
45
|
+
file_upload_id: z.string(),
|
|
46
|
+
status: z.string(),
|
|
47
|
+
mode: z.enum(['single_part', 'multi_part']),
|
|
48
|
+
size_bytes: z.number().int().nonnegative().optional(),
|
|
49
|
+
parts: z.number().int().positive().optional(),
|
|
50
|
+
persistent: z.boolean(),
|
|
51
|
+
});
|
|
52
|
+
export const FileUploadReceiptSchema = z.object({
|
|
53
|
+
operation: z.literal('file-upload.upload'),
|
|
54
|
+
uploaded: z.number().int().nonnegative(),
|
|
55
|
+
attached: z.number().int().nonnegative(),
|
|
56
|
+
target: z.string().nullable(),
|
|
57
|
+
files: z.array(UploadedFileSchema),
|
|
58
|
+
next: z.string().nullable(),
|
|
59
|
+
});
|
|
60
|
+
export const FileUploadAttachReceiptSchema = z.object({
|
|
61
|
+
operation: z.literal('file-upload.attach'),
|
|
62
|
+
attached: z.number().int().nonnegative(),
|
|
63
|
+
target: z.string(),
|
|
64
|
+
created_block_id: z.string().nullable(),
|
|
65
|
+
file_upload_ids: z.array(z.string()),
|
|
66
|
+
file_upload_id: z.string().optional(),
|
|
67
|
+
next: z.string().nullable(),
|
|
68
|
+
});
|
|
69
|
+
export const FileUploadStatusReceiptSchema = z.object({
|
|
70
|
+
operation: z.literal('file-upload.status'),
|
|
71
|
+
checked: z.number().int().nonnegative(),
|
|
72
|
+
states: z.object({
|
|
73
|
+
attach_now: z.number().int().nonnegative(),
|
|
74
|
+
persistent: z.number().int().nonnegative(),
|
|
75
|
+
pending: z.number().int().nonnegative(),
|
|
76
|
+
dead: z.number().int().nonnegative(),
|
|
77
|
+
}),
|
|
78
|
+
uploads: z.array(z.object({ id: z.string(), name: z.string().optional(), status: z.string(), expiry_time: z.string().nullable().optional(), state: z.enum(['attach_now', 'persistent', 'pending', 'dead']) })),
|
|
79
|
+
});
|
|
80
|
+
export const FileUploadListReceiptSchema = z.object({
|
|
81
|
+
operation: z.literal('file-upload.list'),
|
|
82
|
+
count: z.number().int().nonnegative(),
|
|
83
|
+
filter: z.object({ status: z.string().optional(), created_after: z.string().optional(), page_size: z.number().int().positive() }),
|
|
84
|
+
counts_on_page: z.object({ attach_now: z.number().int().nonnegative(), persistent: z.number().int().nonnegative(), pending: z.number().int().nonnegative(), dead: z.number().int().nonnegative() }),
|
|
85
|
+
uploads: FileUploadStatusReceiptSchema.shape.uploads,
|
|
86
|
+
bot: z.string().optional(),
|
|
87
|
+
});
|
|
88
|
+
export async function emitNotionSuccess(command, version, data, durationMs) {
|
|
89
|
+
await writeStdout(`${JSON.stringify({ ok: true, data, error: null, meta: notionMeta(command, version, durationMs) })}\n`);
|
|
90
|
+
}
|
|
91
|
+
export async function emitNotionFailure(command, version, error, durationMs, json) {
|
|
92
|
+
const escliError = toEscliError(error);
|
|
93
|
+
process.exitCode = escliError.exitCode;
|
|
94
|
+
if (json) {
|
|
95
|
+
await writeStdout(`${JSON.stringify({ ok: false, data: null, error: { code: escliError.code, message: escliError.message, remediation: escliError.remediation, details: escliError.details, cause_code: escliError.causeCode }, meta: notionMeta(command, version, durationMs) })}\n`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await writeStderr(`${escliError.message}\n`);
|
|
99
|
+
}
|
|
100
|
+
function notionMeta(command, version, durationMs) {
|
|
101
|
+
return { schema_version: '1', escli_version: version, command, warnings: [], duration_ms: durationMs, next: null, notion_version: NOTION_VERSION };
|
|
102
|
+
}
|
|
103
|
+
function toEscliError(error) {
|
|
104
|
+
if (error instanceof EscliError)
|
|
105
|
+
return error;
|
|
106
|
+
if (error instanceof z.ZodError)
|
|
107
|
+
return new EscliError('input validation failed', { code: ErrorCode.ValidationFailed, exitCode: ExitCodes.Usage, details: error.issues });
|
|
108
|
+
if (error instanceof Error)
|
|
109
|
+
return new EscliError(error.message, { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
110
|
+
return new EscliError('unknown error', { code: ErrorCode.Unknown, details: error });
|
|
111
|
+
}
|
|
112
|
+
export function parseAttachTarget(flags) {
|
|
113
|
+
const attachTo = stringFlag(flags['attach-to']);
|
|
114
|
+
if (attachTo)
|
|
115
|
+
return parseAttachTo(attachTo, flags);
|
|
116
|
+
const attach = stringFlag(flags.attach);
|
|
117
|
+
if (!attach)
|
|
118
|
+
return { kind: 'none' };
|
|
119
|
+
if (attach === 'block') {
|
|
120
|
+
const parent = requireFlag(flags.parent, '--parent is required for --attach block');
|
|
121
|
+
return { kind: 'block', parent, as: asFlag(flags.as), caption: stringFlag(flags.caption) };
|
|
122
|
+
}
|
|
123
|
+
if (attach === 'property') {
|
|
124
|
+
const page = requireFlag(flags.page, '--page is required for --attach property');
|
|
125
|
+
const property = requireFlag(flags.property, '--property is required for --attach property');
|
|
126
|
+
return { kind: 'property', page, property, replace_all: Boolean(flags['replace-all']) };
|
|
127
|
+
}
|
|
128
|
+
if (attach === 'page') {
|
|
129
|
+
const page = requireFlag(flags.page, '--page is required for --attach page');
|
|
130
|
+
const slot = slotFlag(flags.slot);
|
|
131
|
+
return { kind: 'page', page, slot };
|
|
132
|
+
}
|
|
133
|
+
throw buildUsageError('--attach must be block, property, or page');
|
|
134
|
+
}
|
|
135
|
+
export function parseAttachTo(value, flags) {
|
|
136
|
+
const [kind, first, ...rest] = value.split(':');
|
|
137
|
+
if (kind === 'block' && first)
|
|
138
|
+
return { kind: 'block', parent: first, as: asFlag(flags.as), caption: stringFlag(flags.caption) };
|
|
139
|
+
if (kind === 'property' && first && rest.length > 0)
|
|
140
|
+
return { kind: 'property', page: first, property: rest.join(':'), replace_all: Boolean(flags['replace-all']) };
|
|
141
|
+
if ((kind === 'cover' || kind === 'icon') && first)
|
|
142
|
+
return { kind: 'page', page: first, slot: kind };
|
|
143
|
+
throw buildUsageError('--attach-to must be block:<id>, property:<page-id>:<property>, cover:<page-id>, or icon:<page-id>');
|
|
144
|
+
}
|
|
145
|
+
export async function readLocalFile(path) {
|
|
146
|
+
let fileStat;
|
|
147
|
+
try {
|
|
148
|
+
fileStat = await stat(path);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new EscliError(`file not readable: ${path} — check path or permissions`, { code: ErrorCode.FileReadFailed, exitCode: ExitCodes.Usage });
|
|
152
|
+
}
|
|
153
|
+
if (fileStat.isDirectory())
|
|
154
|
+
throw new EscliError(`refusing directory upload: ${path} — pass files or an explicit glob`, { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
155
|
+
try {
|
|
156
|
+
return { path, name: basename(path), size: fileStat.size, bytes: await readFile(path) };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new EscliError(`file not readable: ${path} — check path or permissions`, { code: ErrorCode.FileReadFailed, exitCode: ExitCodes.Usage });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export function decideMode(size, mode) {
|
|
163
|
+
const limit = 20 * 1024 * 1024;
|
|
164
|
+
if (mode === 'single' && size > limit)
|
|
165
|
+
throw new EscliError(`${formatBytes(size)} file exceeds 20 MB; use --mode auto or --mode multi`, { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
166
|
+
if (mode === 'multi')
|
|
167
|
+
return 'multi_part';
|
|
168
|
+
return size <= limit ? 'single_part' : 'multi_part';
|
|
169
|
+
}
|
|
170
|
+
export async function emitRaw(raw) {
|
|
171
|
+
await writeStdout(`${JSON.stringify(stripRawUrls(raw))}\n`);
|
|
172
|
+
}
|
|
173
|
+
export async function attachUploadedFiles(target, files) {
|
|
174
|
+
if (target.kind === 'none')
|
|
175
|
+
return undefined;
|
|
176
|
+
if ((target.kind === 'page') && files.length !== 1)
|
|
177
|
+
throw new EscliError('page cover/icon accepts exactly one file_upload id', { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
178
|
+
if (target.kind === 'block') {
|
|
179
|
+
const children = files.map((file) => ({
|
|
180
|
+
object: 'block',
|
|
181
|
+
type: target.as,
|
|
182
|
+
[target.as]: {
|
|
183
|
+
type: 'file_upload',
|
|
184
|
+
file_upload: { id: file.id },
|
|
185
|
+
...(file.name ? { name: file.name } : {}),
|
|
186
|
+
...(target.caption ? { caption: [{ type: 'text', text: { content: target.caption }, plain_text: target.caption }] } : {}),
|
|
187
|
+
},
|
|
188
|
+
}));
|
|
189
|
+
return notionBlocksAppendChildren(target.parent, children);
|
|
190
|
+
}
|
|
191
|
+
if (target.kind === 'property') {
|
|
192
|
+
const fileObjects = files.map((file) => ({ name: file.name ?? file.id, type: 'file_upload', file_upload: { id: file.id } }));
|
|
193
|
+
return notionPagesPatchProps(target.page, { [target.property]: { files: fileObjects, mode: target.replace_all ? 'replace_all' : 'append' } });
|
|
194
|
+
}
|
|
195
|
+
const file = files[0];
|
|
196
|
+
if (!file)
|
|
197
|
+
throw new EscliError('page cover/icon accepts exactly one file_upload id', { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
198
|
+
return notionPagesPatchProps(target.page, { [target.slot]: { type: 'file_upload', file_upload: { id: file.id }, name: file.name ?? file.id } });
|
|
199
|
+
}
|
|
200
|
+
export function uploadState(item) {
|
|
201
|
+
if (item.status === 'pending')
|
|
202
|
+
return 'pending';
|
|
203
|
+
if (item.status === 'expired' || item.status === 'failed')
|
|
204
|
+
return 'dead';
|
|
205
|
+
if (item.status === 'uploaded' && item.expiry_time)
|
|
206
|
+
return 'attach_now';
|
|
207
|
+
return 'persistent';
|
|
208
|
+
}
|
|
209
|
+
export function fileUploadId(raw) {
|
|
210
|
+
const id = record(raw)?.id;
|
|
211
|
+
if (typeof id === 'string')
|
|
212
|
+
return id;
|
|
213
|
+
throw new EscliError('Notion file upload response missing id', { code: ErrorCode.GateInvalidResponse });
|
|
214
|
+
}
|
|
215
|
+
export function normalizeUpload(raw) {
|
|
216
|
+
const value = record(raw) ?? {};
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
export function listResults(raw) {
|
|
220
|
+
const results = record(raw)?.results;
|
|
221
|
+
return Array.isArray(results) ? results.map((item) => normalizeUpload(item)) : [];
|
|
222
|
+
}
|
|
223
|
+
export function createdBlockId(raw) {
|
|
224
|
+
const results = record(raw)?.results;
|
|
225
|
+
if (!Array.isArray(results))
|
|
226
|
+
return null;
|
|
227
|
+
const first = results[0];
|
|
228
|
+
const id = record(first)?.id;
|
|
229
|
+
return typeof id === 'string' ? id : null;
|
|
230
|
+
}
|
|
231
|
+
export function renderUploadReceipt(data) {
|
|
232
|
+
const lines = [
|
|
233
|
+
renderKv([
|
|
234
|
+
['operation', data.operation],
|
|
235
|
+
['uploaded', data.uploaded],
|
|
236
|
+
['attached', data.attached],
|
|
237
|
+
['target', data.target ?? 'none'],
|
|
238
|
+
]),
|
|
239
|
+
'',
|
|
240
|
+
...data.files.map((file) => `✓ ${file.name} ${file.size_bytes === undefined ? '' : `${formatBytes(file.size_bytes)} `}${file.mode}${file.parts ? ` ${file.parts}/${file.parts}` : ''} → ${file.file_upload_id} · ${file.status} · ${file.persistent ? 'persistent' : 'expires unless attached'}`),
|
|
241
|
+
];
|
|
242
|
+
if (data.next)
|
|
243
|
+
lines.push('', renderKv([['next', data.next]]));
|
|
244
|
+
return lines.join('\n');
|
|
245
|
+
}
|
|
246
|
+
export function renderAttachReceipt(data) {
|
|
247
|
+
const lines = [`attached ${data.attached} file_upload${data.attached === 1 ? '' : 's'} to ${data.target}`];
|
|
248
|
+
if (data.created_block_id)
|
|
249
|
+
lines.push(renderKv([['new block', data.created_block_id]]));
|
|
250
|
+
lines.push('', ...data.file_upload_ids.map((id) => `✓ ${id} uploaded · attached · persistent`));
|
|
251
|
+
if (data.next)
|
|
252
|
+
lines.push(`next: ${data.next}`);
|
|
253
|
+
return lines.join('\n');
|
|
254
|
+
}
|
|
255
|
+
export function renderStatusReceipt(data) {
|
|
256
|
+
const groups = [
|
|
257
|
+
['attach now', 'attach_now', '! ', 'uploaded · not yet persistent'],
|
|
258
|
+
['persistent', 'persistent', '✓ ', 'uploaded · expiry cleared · reusable'],
|
|
259
|
+
['pending', 'pending', '! ', 'pending · finish upload or restart'],
|
|
260
|
+
['dead', 'dead', '✗ ', 're-upload required'],
|
|
261
|
+
];
|
|
262
|
+
const lines = [renderKv([
|
|
263
|
+
['operation', data.operation],
|
|
264
|
+
['checked', data.checked],
|
|
265
|
+
['attach_now', data.states.attach_now],
|
|
266
|
+
['persistent', data.states.persistent],
|
|
267
|
+
['pending', data.states.pending],
|
|
268
|
+
['dead', data.states.dead],
|
|
269
|
+
])];
|
|
270
|
+
for (const [label, state, prefix, fallback] of groups) {
|
|
271
|
+
const uploads = data.uploads.filter((upload) => upload.state === state);
|
|
272
|
+
if (uploads.length === 0)
|
|
273
|
+
continue;
|
|
274
|
+
lines.push('', `${label}:`);
|
|
275
|
+
for (const upload of uploads)
|
|
276
|
+
lines.push(`${prefix}${upload.id} ${upload.status} · ${upload.expiry_time && state === 'attach_now' ? `expires ${relativeExpiry(upload.expiry_time)} · not yet persistent` : fallback}`);
|
|
277
|
+
}
|
|
278
|
+
return lines.join('\n');
|
|
279
|
+
}
|
|
280
|
+
export function renderListReceipt(data) {
|
|
281
|
+
const bot = data.bot ? ` · bot ${data.bot}` : '';
|
|
282
|
+
const lines = [`file_uploads · ${data.count} recent${bot}`];
|
|
283
|
+
const urgent = data.uploads.filter((upload) => upload.state === 'attach_now' || upload.state === 'pending');
|
|
284
|
+
const reusable = data.uploads.filter((upload) => upload.state === 'persistent').slice(0, 2);
|
|
285
|
+
if (urgent.length > 0) {
|
|
286
|
+
lines.push('', 'urgent:');
|
|
287
|
+
for (const upload of urgent)
|
|
288
|
+
lines.push(`! ${upload.id} ${upload.name ?? ''} · ${upload.status} · ${upload.state === 'attach_now' ? `${upload.expiry_time ? `expires ${relativeExpiry(upload.expiry_time)} · ` : ''}attach now` : 'finish upload or restart'}`.replace(/\s+·/u, ' ·'));
|
|
289
|
+
}
|
|
290
|
+
if (reusable.length > 0) {
|
|
291
|
+
lines.push('', 'reusable:');
|
|
292
|
+
for (const upload of reusable)
|
|
293
|
+
lines.push(`✓ ${upload.id} ${upload.name ?? ''} · uploaded · persistent`.replace(/\s+·/u, ' ·'));
|
|
294
|
+
}
|
|
295
|
+
const hidden = data.uploads.filter((upload) => upload.state === 'persistent').length - reusable.length;
|
|
296
|
+
if (hidden > 0)
|
|
297
|
+
lines.push('', `${hidden} stable uploads hidden`);
|
|
298
|
+
if (urgent.length > 0)
|
|
299
|
+
lines.push(`next: escli notion file-upload status ${urgent.map((upload) => upload.id).join(' ')}`);
|
|
300
|
+
return lines.join('\n');
|
|
301
|
+
}
|
|
302
|
+
export function requireUploaded(upload) {
|
|
303
|
+
if (upload.status === 'pending')
|
|
304
|
+
throw new EscliError(`${upload.id ?? 'file_upload'} is still pending; finish upload or create a new upload`, { code: ErrorCode.NotionValidation, exitCode: ExitCodes.Usage, causeCode: 'file_upload.pending' });
|
|
305
|
+
if (upload.status === 'expired')
|
|
306
|
+
throw new EscliError(`${upload.id ?? 'file_upload'} expired before first attachment; re-upload the file`, { code: ErrorCode.NotionValidation, exitCode: ExitCodes.Usage, causeCode: 'file_upload.expired' });
|
|
307
|
+
if (upload.status === 'failed')
|
|
308
|
+
throw new EscliError(`${upload.id ?? 'file_upload'} failed in Notion storage; create a new upload`, { code: ErrorCode.NotionValidation, exitCode: ExitCodes.Usage, causeCode: 'file_upload.failed' });
|
|
309
|
+
}
|
|
310
|
+
function stripRawUrls(value) {
|
|
311
|
+
if (Array.isArray(value))
|
|
312
|
+
return value.map(stripRawUrls);
|
|
313
|
+
const valueRecord = record(value);
|
|
314
|
+
if (!valueRecord)
|
|
315
|
+
return value;
|
|
316
|
+
return Object.fromEntries(Object.entries(valueRecord).map(([key, entry]) => [key, (key === 'upload_url' || key === 'complete_url') && typeof entry === 'string' ? entry.split('?')[0] : stripRawUrls(entry)]));
|
|
317
|
+
}
|
|
318
|
+
function asFlag(value) {
|
|
319
|
+
if (value === 'image' || value === 'pdf' || value === 'video' || value === 'audio')
|
|
320
|
+
return value;
|
|
321
|
+
return 'file';
|
|
322
|
+
}
|
|
323
|
+
function slotFlag(value) {
|
|
324
|
+
if (value === 'cover' || value === 'icon')
|
|
325
|
+
return value;
|
|
326
|
+
throw buildUsageError('--slot must be cover or icon');
|
|
327
|
+
}
|
|
328
|
+
function stringFlag(value) {
|
|
329
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
330
|
+
}
|
|
331
|
+
function requireFlag(value, message) {
|
|
332
|
+
const output = stringFlag(value);
|
|
333
|
+
if (!output)
|
|
334
|
+
throw buildUsageError(message);
|
|
335
|
+
return output;
|
|
336
|
+
}
|
|
337
|
+
export function targetLabel(target) {
|
|
338
|
+
if (target.kind === 'none')
|
|
339
|
+
return null;
|
|
340
|
+
if (target.kind === 'block')
|
|
341
|
+
return `block ${target.parent}`;
|
|
342
|
+
if (target.kind === 'property')
|
|
343
|
+
return `page property ${target.page} ${target.property}`;
|
|
344
|
+
return `page ${target.slot} ${target.page}`;
|
|
345
|
+
}
|
|
346
|
+
export function nextForTarget(target, createdBlock) {
|
|
347
|
+
if (target.kind === 'block' && createdBlock)
|
|
348
|
+
return `escli notion block get ${createdBlock}`;
|
|
349
|
+
if (target.kind === 'property')
|
|
350
|
+
return `escli notion fetch ${target.page} --properties ${target.property}`;
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
function record(value) {
|
|
354
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
355
|
+
}
|
|
356
|
+
function formatBytes(bytes) {
|
|
357
|
+
if (bytes >= 1024 * 1024)
|
|
358
|
+
return `${Math.round((bytes / 1024 / 1024) * 10) / 10} MB`;
|
|
359
|
+
if (bytes >= 1024)
|
|
360
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
361
|
+
return `${bytes} B`;
|
|
362
|
+
}
|
|
363
|
+
function relativeExpiry(value) {
|
|
364
|
+
const ms = Date.parse(value) - Date.now();
|
|
365
|
+
if (!Number.isFinite(ms))
|
|
366
|
+
return value;
|
|
367
|
+
const minutes = Math.max(0, Math.round(ms / 60_000));
|
|
368
|
+
if (minutes >= 60)
|
|
369
|
+
return `in ${Math.round(minutes / 60)}h`;
|
|
370
|
+
return `in ${minutes}m`;
|
|
371
|
+
}
|
|
372
|
+
//# sourceMappingURL=shared.js.map
|