dotmd-cli 0.7.6 → 0.8.1
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 +120 -41
- package/bin/dotmd.mjs +99 -3
- package/package.json +14 -3
- package/src/ai.mjs +50 -0
- package/src/completions.mjs +12 -5
- package/src/config.mjs +10 -3
- package/src/deps.mjs +249 -0
- package/src/diff.mjs +2 -28
- package/src/export.mjs +344 -0
- package/src/extractors.mjs +2 -2
- package/src/fix-refs.mjs +102 -52
- package/src/index.mjs +15 -4
- package/src/init.mjs +88 -4
- package/src/lifecycle.mjs +13 -8
- package/src/lint.mjs +36 -0
- package/src/new.mjs +15 -1
- package/src/notion.mjs +528 -0
- package/src/query.mjs +36 -4
- package/src/render.mjs +22 -4
- package/src/stats.mjs +161 -0
- package/src/summary.mjs +63 -0
- package/src/util.mjs +5 -2
- package/src/validate.mjs +3 -3
- package/src/watch.mjs +12 -9
package/src/notion.mjs
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { buildIndex } from './index.mjs';
|
|
5
|
+
import { asString, toRepoPath, die, warn } from './util.mjs';
|
|
6
|
+
import { bold, green, yellow, dim } from './color.mjs';
|
|
7
|
+
|
|
8
|
+
let notionClient;
|
|
9
|
+
let notionToMd;
|
|
10
|
+
|
|
11
|
+
async function loadDeps() {
|
|
12
|
+
if (notionClient) return;
|
|
13
|
+
try {
|
|
14
|
+
const { Client } = await import('@notionhq/client');
|
|
15
|
+
const { NotionToMarkdown } = await import('notion-to-md');
|
|
16
|
+
notionClient = Client;
|
|
17
|
+
notionToMd = NotionToMarkdown;
|
|
18
|
+
} catch {
|
|
19
|
+
die('Notion dependencies not installed. Run: npm install @notionhq/client notion-to-md');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getClient(config) {
|
|
24
|
+
const token = config.raw?.notion?.token ?? process.env.NOTION_TOKEN;
|
|
25
|
+
if (!token) die('No Notion token. Set NOTION_TOKEN env var or add notion.token to your config.');
|
|
26
|
+
return new notionClient({ auth: token });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getDbId(argv, config) {
|
|
30
|
+
const positional = [];
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
if (argv[i] === '--config') { i++; continue; }
|
|
33
|
+
if (argv[i].startsWith('-')) continue;
|
|
34
|
+
positional.push(argv[i]);
|
|
35
|
+
}
|
|
36
|
+
const id = positional[0] ?? config.raw?.notion?.database;
|
|
37
|
+
if (!id) die('No database ID. Pass as argument or set notion.database in config.');
|
|
38
|
+
return id;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasFlag(argv, flag) {
|
|
42
|
+
return argv.includes(flag);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Property mapping: Notion → frontmatter ─────────────────────────────
|
|
46
|
+
|
|
47
|
+
function mapPropertiesToFrontmatter(properties, config) {
|
|
48
|
+
const fm = {};
|
|
49
|
+
const map = config.raw?.notion?.propertyMap ?? {};
|
|
50
|
+
|
|
51
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
52
|
+
const key = map[name] ?? name.toLowerCase().replace(/\s+/g, '_');
|
|
53
|
+
switch (prop.type) {
|
|
54
|
+
case 'title':
|
|
55
|
+
fm.title = prop.title.map(t => t.plain_text).join('');
|
|
56
|
+
break;
|
|
57
|
+
case 'rich_text':
|
|
58
|
+
fm[key] = prop.rich_text.map(t => t.plain_text).join('');
|
|
59
|
+
break;
|
|
60
|
+
case 'select':
|
|
61
|
+
fm[key] = prop.select?.name ?? null;
|
|
62
|
+
break;
|
|
63
|
+
case 'multi_select':
|
|
64
|
+
fm[key] = prop.multi_select.map(o => o.name);
|
|
65
|
+
break;
|
|
66
|
+
case 'date':
|
|
67
|
+
fm[key] = prop.date?.start ?? null;
|
|
68
|
+
break;
|
|
69
|
+
case 'checkbox':
|
|
70
|
+
fm[key] = prop.checkbox;
|
|
71
|
+
break;
|
|
72
|
+
case 'number':
|
|
73
|
+
fm[key] = prop.number;
|
|
74
|
+
break;
|
|
75
|
+
case 'url':
|
|
76
|
+
fm[key] = prop.url;
|
|
77
|
+
break;
|
|
78
|
+
case 'email':
|
|
79
|
+
fm[key] = prop.email;
|
|
80
|
+
break;
|
|
81
|
+
case 'status':
|
|
82
|
+
fm[key] = prop.status?.name?.toLowerCase() ?? null;
|
|
83
|
+
break;
|
|
84
|
+
case 'people':
|
|
85
|
+
fm[key] = prop.people.map(p => p.name ?? p.id);
|
|
86
|
+
break;
|
|
87
|
+
case 'relation':
|
|
88
|
+
// Store as page IDs for now; could resolve titles
|
|
89
|
+
fm[key] = prop.relation.map(r => r.id);
|
|
90
|
+
break;
|
|
91
|
+
case 'formula':
|
|
92
|
+
case 'rollup':
|
|
93
|
+
case 'created_time':
|
|
94
|
+
case 'last_edited_time':
|
|
95
|
+
case 'created_by':
|
|
96
|
+
case 'last_edited_by':
|
|
97
|
+
break; // skip computed/system
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return fm;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Property mapping: frontmatter → Notion ─────────────────────────────
|
|
104
|
+
|
|
105
|
+
function mapFrontmatterToProperties(doc, dbProperties, config) {
|
|
106
|
+
const reverseMap = {};
|
|
107
|
+
const map = config.raw?.notion?.propertyMap ?? {};
|
|
108
|
+
for (const [notionName, fmKey] of Object.entries(map)) {
|
|
109
|
+
reverseMap[fmKey] = notionName;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const properties = {};
|
|
113
|
+
|
|
114
|
+
for (const [notionName, propDef] of Object.entries(dbProperties)) {
|
|
115
|
+
const fmKey = map[notionName] ?? notionName.toLowerCase().replace(/\s+/g, '_');
|
|
116
|
+
|
|
117
|
+
// Title property
|
|
118
|
+
if (propDef.type === 'title') {
|
|
119
|
+
properties[notionName] = {
|
|
120
|
+
title: [{ type: 'text', text: { content: doc.title ?? '' } }],
|
|
121
|
+
};
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check if we have a frontmatter value for this property
|
|
126
|
+
const value = doc[fmKey] ?? null;
|
|
127
|
+
if (value === null || value === undefined) continue;
|
|
128
|
+
|
|
129
|
+
switch (propDef.type) {
|
|
130
|
+
case 'rich_text':
|
|
131
|
+
properties[notionName] = {
|
|
132
|
+
rich_text: [{ type: 'text', text: { content: String(value) } }],
|
|
133
|
+
};
|
|
134
|
+
break;
|
|
135
|
+
case 'select':
|
|
136
|
+
if (typeof value === 'string' && value) {
|
|
137
|
+
properties[notionName] = { select: { name: value } };
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
case 'multi_select':
|
|
141
|
+
if (Array.isArray(value)) {
|
|
142
|
+
properties[notionName] = { multi_select: value.map(v => ({ name: String(v) })) };
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
case 'date':
|
|
146
|
+
if (typeof value === 'string' && value) {
|
|
147
|
+
properties[notionName] = { date: { start: value } };
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
case 'checkbox':
|
|
151
|
+
properties[notionName] = { checkbox: Boolean(value) };
|
|
152
|
+
break;
|
|
153
|
+
case 'number':
|
|
154
|
+
if (typeof value === 'number') {
|
|
155
|
+
properties[notionName] = { number: value };
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
case 'url':
|
|
159
|
+
if (typeof value === 'string' && value) {
|
|
160
|
+
properties[notionName] = { url: value };
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
case 'email':
|
|
164
|
+
if (typeof value === 'string' && value) {
|
|
165
|
+
properties[notionName] = { email: value };
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
case 'status':
|
|
169
|
+
if (typeof value === 'string' && value) {
|
|
170
|
+
properties[notionName] = { status: { name: value } };
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
// Skip: formula, rollup, relation (complex), created_time, etc.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return properties;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Serialization helpers ──────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function serializeFrontmatter(fm) {
|
|
183
|
+
const lines = [];
|
|
184
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
185
|
+
if (value === null || value === undefined) continue;
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
if (value.length === 0) continue;
|
|
188
|
+
lines.push(`${key}:`);
|
|
189
|
+
for (const item of value) lines.push(` - ${item}`);
|
|
190
|
+
} else {
|
|
191
|
+
lines.push(`${key}: ${value}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return lines.join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function slugify(text) {
|
|
198
|
+
return text.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'untitled';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function loadBody(doc, config) {
|
|
202
|
+
const raw = readFileSync(path.join(config.repoRoot, doc.path), 'utf8');
|
|
203
|
+
const { body } = extractFrontmatter(raw);
|
|
204
|
+
return body ?? '';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Paginated query helper ─────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
async function queryAllPages(client, dbId) {
|
|
210
|
+
const pages = [];
|
|
211
|
+
let cursor;
|
|
212
|
+
do {
|
|
213
|
+
const response = await client.databases.query({
|
|
214
|
+
database_id: dbId,
|
|
215
|
+
start_cursor: cursor,
|
|
216
|
+
});
|
|
217
|
+
pages.push(...response.results);
|
|
218
|
+
cursor = response.has_more ? response.next_cursor : undefined;
|
|
219
|
+
} while (cursor);
|
|
220
|
+
return pages;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function findPageByTitle(client, dbId, title) {
|
|
224
|
+
const response = await client.databases.query({
|
|
225
|
+
database_id: dbId,
|
|
226
|
+
filter: { property: 'title', title: { equals: title } },
|
|
227
|
+
page_size: 1,
|
|
228
|
+
});
|
|
229
|
+
return response.results[0] ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Import: Notion → local .md ─────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
export async function runNotionImport(argv, config, opts = {}) {
|
|
235
|
+
await loadDeps();
|
|
236
|
+
const client = getClient(config);
|
|
237
|
+
const n2m = new notionToMd({ notionClient: client });
|
|
238
|
+
const dbId = getDbId(argv, config);
|
|
239
|
+
const force = hasFlag(argv, '--force');
|
|
240
|
+
const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
|
|
241
|
+
|
|
242
|
+
process.stdout.write(`Importing from Notion database ${dim(dbId)}...\n`);
|
|
243
|
+
|
|
244
|
+
const pages = await queryAllPages(client, dbId);
|
|
245
|
+
process.stdout.write(`Found ${pages.length} pages.\n\n`);
|
|
246
|
+
|
|
247
|
+
let created = 0, skipped = 0, updated = 0;
|
|
248
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
249
|
+
|
|
250
|
+
for (const page of pages) {
|
|
251
|
+
const fm = mapPropertiesToFrontmatter(page.properties, config);
|
|
252
|
+
const title = fm.title ?? 'Untitled';
|
|
253
|
+
delete fm.title; // title goes in heading, not frontmatter
|
|
254
|
+
|
|
255
|
+
// Add notion_id for sync tracking
|
|
256
|
+
fm.notion_id = page.id;
|
|
257
|
+
if (!fm.updated) fm.updated = page.last_edited_time?.slice(0, 10) ?? new Date().toISOString().slice(0, 10);
|
|
258
|
+
|
|
259
|
+
const slug = slugify(title);
|
|
260
|
+
const filePath = path.join(config.docsRoot, slug + '.md');
|
|
261
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
262
|
+
|
|
263
|
+
if (existsSync(filePath) && !force) {
|
|
264
|
+
process.stdout.write(`${prefix}${dim('skip')} ${repoPath} (exists, use --force to overwrite)\n`);
|
|
265
|
+
skipped++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Convert blocks to markdown
|
|
270
|
+
let body = '';
|
|
271
|
+
try {
|
|
272
|
+
const mdBlocks = await n2m.pageToMarkdown(page.id);
|
|
273
|
+
body = n2m.toMarkdownString(mdBlocks).parent ?? '';
|
|
274
|
+
} catch (err) {
|
|
275
|
+
warn(`Failed to convert blocks for "${title}": ${err.message}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
|
|
279
|
+
|
|
280
|
+
if (!dryRun) {
|
|
281
|
+
writeFileSync(filePath, content, 'utf8');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const action = existsSync(filePath) ? 'update' : 'create';
|
|
285
|
+
process.stdout.write(`${prefix}${green(action)} ${repoPath}\n`);
|
|
286
|
+
if (action === 'update') updated++;
|
|
287
|
+
else created++;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
process.stdout.write(`\n${prefix}Done: ${created} created, ${updated} updated, ${skipped} skipped.\n`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Export: local docs → Notion ─────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
export async function runNotionExport(argv, config, opts = {}) {
|
|
296
|
+
await loadDeps();
|
|
297
|
+
const client = getClient(config);
|
|
298
|
+
const dbId = getDbId(argv, config);
|
|
299
|
+
const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
|
|
300
|
+
|
|
301
|
+
process.stdout.write(`Exporting to Notion database ${dim(dbId)}...\n`);
|
|
302
|
+
|
|
303
|
+
// Get database schema for property mapping
|
|
304
|
+
const dbInfo = await client.databases.retrieve({ database_id: dbId });
|
|
305
|
+
const dbProperties = dbInfo.properties;
|
|
306
|
+
|
|
307
|
+
const index = buildIndex(config);
|
|
308
|
+
process.stdout.write(`Found ${index.docs.length} local docs.\n\n`);
|
|
309
|
+
|
|
310
|
+
let created = 0, updated = 0;
|
|
311
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
312
|
+
|
|
313
|
+
for (const doc of index.docs) {
|
|
314
|
+
const properties = mapFrontmatterToProperties(doc, dbProperties, config);
|
|
315
|
+
const body = loadBody(doc, config);
|
|
316
|
+
|
|
317
|
+
const existing = await findPageByTitle(client, dbId, doc.title);
|
|
318
|
+
|
|
319
|
+
if (dryRun) {
|
|
320
|
+
process.stdout.write(`${prefix}${existing ? 'update' : 'create'} ${doc.path}\n`);
|
|
321
|
+
if (existing) updated++;
|
|
322
|
+
else created++;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
if (existing) {
|
|
328
|
+
await client.pages.update({
|
|
329
|
+
page_id: existing.id,
|
|
330
|
+
properties,
|
|
331
|
+
markdown: body,
|
|
332
|
+
});
|
|
333
|
+
process.stdout.write(`${green('update')} ${doc.path}\n`);
|
|
334
|
+
updated++;
|
|
335
|
+
} else {
|
|
336
|
+
await client.pages.create({
|
|
337
|
+
parent: { database_id: dbId },
|
|
338
|
+
properties,
|
|
339
|
+
markdown: body,
|
|
340
|
+
});
|
|
341
|
+
process.stdout.write(`${green('create')} ${doc.path}\n`);
|
|
342
|
+
created++;
|
|
343
|
+
}
|
|
344
|
+
} catch (err) {
|
|
345
|
+
warn(`Failed to export "${doc.title}": ${err.message}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
process.stdout.write(`\n${prefix}Done: ${created} created, ${updated} updated.\n`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Sync: bidirectional merge ───────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
export async function runNotionSync(argv, config, opts = {}) {
|
|
355
|
+
await loadDeps();
|
|
356
|
+
const client = getClient(config);
|
|
357
|
+
const n2m = new notionToMd({ notionClient: client });
|
|
358
|
+
const dbId = getDbId(argv, config);
|
|
359
|
+
const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
|
|
360
|
+
|
|
361
|
+
process.stdout.write(`Syncing with Notion database ${dim(dbId)}...\n\n`);
|
|
362
|
+
|
|
363
|
+
// Get database schema
|
|
364
|
+
const dbInfo = await client.databases.retrieve({ database_id: dbId });
|
|
365
|
+
const dbProperties = dbInfo.properties;
|
|
366
|
+
|
|
367
|
+
// Get remote pages
|
|
368
|
+
const remotePages = await queryAllPages(client, dbId);
|
|
369
|
+
const remoteBySlug = new Map();
|
|
370
|
+
for (const page of remotePages) {
|
|
371
|
+
const title = page.properties.title?.title?.map(t => t.plain_text).join('') ??
|
|
372
|
+
Object.values(page.properties).find(p => p.type === 'title')?.title?.map(t => t.plain_text).join('') ?? '';
|
|
373
|
+
const slug = slugify(title);
|
|
374
|
+
remoteBySlug.set(slug, { page, title });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Get local docs
|
|
378
|
+
const index = buildIndex(config);
|
|
379
|
+
const localBySlug = new Map();
|
|
380
|
+
for (const doc of index.docs) {
|
|
381
|
+
const slug = path.basename(doc.path, '.md');
|
|
382
|
+
localBySlug.set(slug, doc);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const allSlugs = new Set([...remoteBySlug.keys(), ...localBySlug.keys()]);
|
|
386
|
+
let pulled = 0, pushed = 0, conflicts = 0, skipped = 0;
|
|
387
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
388
|
+
|
|
389
|
+
for (const slug of allSlugs) {
|
|
390
|
+
const remote = remoteBySlug.get(slug);
|
|
391
|
+
const local = localBySlug.get(slug);
|
|
392
|
+
|
|
393
|
+
if (remote && !local) {
|
|
394
|
+
// New in Notion → pull
|
|
395
|
+
const fm = mapPropertiesToFrontmatter(remote.page.properties, config);
|
|
396
|
+
const title = fm.title ?? remote.title;
|
|
397
|
+
delete fm.title;
|
|
398
|
+
fm.notion_id = remote.page.id;
|
|
399
|
+
if (!fm.updated) fm.updated = remote.page.last_edited_time?.slice(0, 10);
|
|
400
|
+
|
|
401
|
+
let body = '';
|
|
402
|
+
try {
|
|
403
|
+
const mdBlocks = await n2m.pageToMarkdown(remote.page.id);
|
|
404
|
+
body = n2m.toMarkdownString(mdBlocks).parent ?? '';
|
|
405
|
+
} catch { /* skip body on error */ }
|
|
406
|
+
|
|
407
|
+
const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
|
|
408
|
+
const filePath = path.join(config.docsRoot, slug + '.md');
|
|
409
|
+
|
|
410
|
+
if (!dryRun) writeFileSync(filePath, content, 'utf8');
|
|
411
|
+
process.stdout.write(`${prefix}${green('pull')} ${slug} (new in Notion)\n`);
|
|
412
|
+
pulled++;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (local && !remote) {
|
|
417
|
+
// New locally → push
|
|
418
|
+
const properties = mapFrontmatterToProperties(local, dbProperties, config);
|
|
419
|
+
const body = loadBody(local, config);
|
|
420
|
+
|
|
421
|
+
if (!dryRun) {
|
|
422
|
+
try {
|
|
423
|
+
await client.pages.create({
|
|
424
|
+
parent: { database_id: dbId },
|
|
425
|
+
properties,
|
|
426
|
+
markdown: body,
|
|
427
|
+
});
|
|
428
|
+
} catch (err) {
|
|
429
|
+
warn(`Failed to push "${local.title}": ${err.message}`);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
process.stdout.write(`${prefix}${green('push')} ${slug} (new locally)\n`);
|
|
434
|
+
pushed++;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Both exist — compare timestamps
|
|
439
|
+
const remoteTime = remote.page.last_edited_time?.slice(0, 10) ?? '';
|
|
440
|
+
const localTime = local.updated ?? '';
|
|
441
|
+
|
|
442
|
+
if (remoteTime === localTime) {
|
|
443
|
+
skipped++;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (remoteTime > localTime) {
|
|
448
|
+
// Notion is newer → pull
|
|
449
|
+
const fm = mapPropertiesToFrontmatter(remote.page.properties, config);
|
|
450
|
+
const title = fm.title ?? remote.title;
|
|
451
|
+
delete fm.title;
|
|
452
|
+
fm.notion_id = remote.page.id;
|
|
453
|
+
fm.updated = remoteTime;
|
|
454
|
+
|
|
455
|
+
let body = '';
|
|
456
|
+
try {
|
|
457
|
+
const mdBlocks = await n2m.pageToMarkdown(remote.page.id);
|
|
458
|
+
body = n2m.toMarkdownString(mdBlocks).parent ?? '';
|
|
459
|
+
} catch { /* skip body on error */ }
|
|
460
|
+
|
|
461
|
+
const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
|
|
462
|
+
const filePath = path.join(config.repoRoot, local.path);
|
|
463
|
+
|
|
464
|
+
if (!dryRun) writeFileSync(filePath, content, 'utf8');
|
|
465
|
+
process.stdout.write(`${prefix}${green('pull')} ${slug} (Notion newer: ${remoteTime} > ${localTime})\n`);
|
|
466
|
+
pulled++;
|
|
467
|
+
} else {
|
|
468
|
+
// Local is newer → push
|
|
469
|
+
const properties = mapFrontmatterToProperties(local, dbProperties, config);
|
|
470
|
+
const body = loadBody(local, config);
|
|
471
|
+
|
|
472
|
+
if (!dryRun) {
|
|
473
|
+
try {
|
|
474
|
+
await client.pages.update({
|
|
475
|
+
page_id: remote.page.id,
|
|
476
|
+
properties,
|
|
477
|
+
markdown: body,
|
|
478
|
+
});
|
|
479
|
+
} catch (err) {
|
|
480
|
+
warn(`Failed to push "${local.title}": ${err.message}`);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
process.stdout.write(`${prefix}${green('push')} ${slug} (local newer: ${localTime} > ${remoteTime})\n`);
|
|
485
|
+
pushed++;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
process.stdout.write(`\n${prefix}Done: ${pulled} pulled, ${pushed} pushed, ${skipped} unchanged.\n`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── CLI dispatcher ──────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
export async function runNotion(argv, config, opts = {}) {
|
|
495
|
+
// Filter out global flags before finding subcommand
|
|
496
|
+
const filtered = [];
|
|
497
|
+
for (let i = 0; i < argv.length; i++) {
|
|
498
|
+
if (argv[i] === '--config') { i++; continue; }
|
|
499
|
+
if (argv[i] === '--verbose') continue;
|
|
500
|
+
filtered.push(argv[i]);
|
|
501
|
+
}
|
|
502
|
+
const subcommand = filtered[0];
|
|
503
|
+
const restArgs = filtered.slice(1);
|
|
504
|
+
|
|
505
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
506
|
+
process.stdout.write(`dotmd notion — Notion database integration
|
|
507
|
+
|
|
508
|
+
Subcommands:
|
|
509
|
+
import <database-id> Pull Notion database → local .md files
|
|
510
|
+
export <database-id> Push local docs → Notion database rows
|
|
511
|
+
sync <database-id> Bidirectional sync (merge by slug)
|
|
512
|
+
|
|
513
|
+
Options:
|
|
514
|
+
--force Overwrite existing files on import
|
|
515
|
+
--dry-run, -n Preview without changes
|
|
516
|
+
|
|
517
|
+
Requires NOTION_TOKEN env var or notion.token in config.
|
|
518
|
+
Database ID can be set in config as notion.database.
|
|
519
|
+
`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (subcommand === 'import') return runNotionImport(restArgs, config, opts);
|
|
524
|
+
if (subcommand === 'export') return runNotionExport(restArgs, config, opts);
|
|
525
|
+
if (subcommand === 'sync') return runNotionSync(restArgs, config, opts);
|
|
526
|
+
|
|
527
|
+
die(`Unknown notion subcommand: ${subcommand}\nRun \`dotmd notion --help\` for usage.`);
|
|
528
|
+
}
|
package/src/query.mjs
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { capitalize, toSlug, warn } from './util.mjs';
|
|
2
4
|
import { renderProgressBar } from './render.mjs';
|
|
3
5
|
import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
|
|
4
6
|
import { getGitLastModified } from './git.mjs';
|
|
7
|
+
import { extractFrontmatter } from './frontmatter.mjs';
|
|
8
|
+
import { summarizeDocBody } from './ai.mjs';
|
|
9
|
+
import { dim } from './color.mjs';
|
|
5
10
|
|
|
6
11
|
export function runFocus(index, argv, config) {
|
|
7
12
|
const statusFilter = argv[0] ?? 'active';
|
|
@@ -36,11 +41,16 @@ export function runQuery(index, argv, config) {
|
|
|
36
41
|
const docs = filterDocs(index.docs, filters, config);
|
|
37
42
|
|
|
38
43
|
if (filters.json) {
|
|
44
|
+
if (filters.summarize) {
|
|
45
|
+
for (let i = 0; i < docs.length && i < filters.summarizeLimit; i++) {
|
|
46
|
+
docs[i].aiSummary = getDocSummary(docs[i], config);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
39
49
|
process.stdout.write(`${JSON.stringify({ filters, count: docs.length, docs }, null, 2)}\n`);
|
|
40
50
|
return;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
renderQueryResults(docs, filters);
|
|
53
|
+
renderQueryResults(docs, filters, config);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export function parseQueryArgs(argv) {
|
|
@@ -50,6 +60,7 @@ export function parseQueryArgs(argv) {
|
|
|
50
60
|
updatedSince: null, limit: 20, all: false, sort: 'updated',
|
|
51
61
|
stale: false, hasNextStep: false, hasBlockers: false,
|
|
52
62
|
checklistOpen: false, json: false, git: false,
|
|
63
|
+
summarize: false, summarizeLimit: 5, model: undefined,
|
|
53
64
|
};
|
|
54
65
|
|
|
55
66
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -74,6 +85,9 @@ export function parseQueryArgs(argv) {
|
|
|
74
85
|
if (arg === '--checklist-open') { filters.checklistOpen = true; continue; }
|
|
75
86
|
if (arg === '--json') { filters.json = true; continue; }
|
|
76
87
|
if (arg === '--git') { filters.git = true; continue; }
|
|
88
|
+
if (arg === '--summarize') { filters.summarize = true; continue; }
|
|
89
|
+
if (arg === '--summarize-limit' && next) { filters.summarizeLimit = Number.parseInt(next, 10) || 5; i += 1; continue; }
|
|
90
|
+
if (arg === '--model' && next) { filters.model = next; i += 1; continue; }
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
return filters;
|
|
@@ -116,7 +130,20 @@ export function filterDocs(docs, filters, config) {
|
|
|
116
130
|
return filters.all ? result : result.slice(0, filters.limit);
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
function
|
|
133
|
+
function getDocSummary(doc, config) {
|
|
134
|
+
try {
|
|
135
|
+
const absPath = path.resolve(config.repoRoot, doc.path);
|
|
136
|
+
const raw = readFileSync(absPath, 'utf8');
|
|
137
|
+
const { body } = extractFrontmatter(raw);
|
|
138
|
+
if (!body?.trim()) return null;
|
|
139
|
+
const meta = { title: doc.title, status: doc.status, path: doc.path };
|
|
140
|
+
return config.hooks.summarizeDoc
|
|
141
|
+
? config.hooks.summarizeDoc(body, meta)
|
|
142
|
+
: summarizeDocBody(body, meta, { model: undefined });
|
|
143
|
+
} catch { return null; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderQueryResults(docs, filters, config) {
|
|
120
147
|
process.stdout.write('Query\n\n');
|
|
121
148
|
process.stdout.write(`- results: ${docs.length}\n`);
|
|
122
149
|
if (filters.statuses?.length) process.stdout.write(`- status: ${filters.statuses.join(', ')}\n`);
|
|
@@ -138,7 +165,8 @@ function renderQueryResults(docs, filters) {
|
|
|
138
165
|
|
|
139
166
|
if (docs.length === 0) { process.stdout.write('No matching docs.\n'); return; }
|
|
140
167
|
|
|
141
|
-
for (
|
|
168
|
+
for (let idx = 0; idx < docs.length; idx++) {
|
|
169
|
+
const doc = docs[idx];
|
|
142
170
|
process.stdout.write(`- ${doc.title}\n`);
|
|
143
171
|
process.stdout.write(` status: ${doc.status}\n`);
|
|
144
172
|
process.stdout.write(` updated: ${doc.updated ?? 'n/a'}\n`);
|
|
@@ -155,6 +183,10 @@ function renderQueryResults(docs, filters) {
|
|
|
155
183
|
if (doc.executionMode) process.stdout.write(` execution-mode: ${doc.executionMode}\n`);
|
|
156
184
|
if (doc.blockers?.length) process.stdout.write(` blockers: ${doc.blockers.join('; ')}\n`);
|
|
157
185
|
if (doc.checklist?.total) process.stdout.write(` checklist: ${doc.checklist.completed}/${doc.checklist.total} complete\n`);
|
|
186
|
+
if (filters.summarize && idx < filters.summarizeLimit) {
|
|
187
|
+
const summary = getDocSummary(doc, config);
|
|
188
|
+
if (summary) process.stdout.write(` ${dim('ai-summary:')} ${summary}\n`);
|
|
189
|
+
}
|
|
158
190
|
process.stdout.write('\n');
|
|
159
191
|
}
|
|
160
192
|
}
|
package/src/render.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { capitalize, toSlug, truncate, warn } from './util.mjs';
|
|
2
|
-
import {
|
|
4
|
+
import { extractFrontmatter } from './frontmatter.mjs';
|
|
5
|
+
import { summarizeDocBody } from './ai.mjs';
|
|
6
|
+
import { bold, red, yellow, green, dim } from './color.mjs';
|
|
3
7
|
|
|
4
8
|
export function renderCompactList(index, config) {
|
|
5
9
|
const defaultRenderer = (idx) => _renderCompactList(idx, config);
|
|
@@ -61,8 +65,8 @@ export function renderVerboseList(index, config) {
|
|
|
61
65
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
export function renderContext(index, config) {
|
|
65
|
-
const defaultRenderer = (idx) => _renderContext(idx, config);
|
|
68
|
+
export function renderContext(index, config, opts = {}) {
|
|
69
|
+
const defaultRenderer = (idx) => _renderContext(idx, config, opts);
|
|
66
70
|
if (config.hooks.renderContext) {
|
|
67
71
|
try { return config.hooks.renderContext(index, defaultRenderer); }
|
|
68
72
|
catch (err) { warn(`Hook 'renderContext' threw: ${err.message}`); }
|
|
@@ -70,7 +74,7 @@ export function renderContext(index, config) {
|
|
|
70
74
|
return defaultRenderer(index);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
function _renderContext(index, config) {
|
|
77
|
+
function _renderContext(index, config, opts = {}) {
|
|
74
78
|
const today = new Date().toISOString().slice(0, 10);
|
|
75
79
|
const lines = [`BRIEFING (${today})`, ''];
|
|
76
80
|
const ctx = config.context;
|
|
@@ -91,6 +95,20 @@ function _renderContext(index, config) {
|
|
|
91
95
|
? truncate(doc.nextStep, ctx.truncateNextStep || 80)
|
|
92
96
|
: '(no next step)';
|
|
93
97
|
lines.push(` ${slug} next: ${next}`);
|
|
98
|
+
if (opts.summarize) {
|
|
99
|
+
try {
|
|
100
|
+
const absPath = path.resolve(config.repoRoot, doc.path);
|
|
101
|
+
const raw = readFileSync(absPath, 'utf8');
|
|
102
|
+
const { body } = extractFrontmatter(raw);
|
|
103
|
+
const meta = { title: doc.title, status: doc.status, path: doc.path };
|
|
104
|
+
const summary = config.hooks.summarizeDoc
|
|
105
|
+
? config.hooks.summarizeDoc(body, meta)
|
|
106
|
+
: summarizeDocBody(body, meta, { model: opts.model });
|
|
107
|
+
if (summary) {
|
|
108
|
+
lines.push(` ${''.padEnd(maxSlug)} ${dim('ai: ' + truncate(summary, 120))}`);
|
|
109
|
+
}
|
|
110
|
+
} catch { /* skip on failure */ }
|
|
111
|
+
}
|
|
94
112
|
}
|
|
95
113
|
lines.push('');
|
|
96
114
|
}
|