@moxn/kb-migrate 0.4.19 → 0.4.20

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/client.d.ts CHANGED
@@ -53,9 +53,10 @@ export declare class MoxnClient {
53
53
  */
54
54
  addDatabaseColumn(databaseId: string, input: {
55
55
  name: string;
56
- type: 'select' | 'multi_select';
56
+ type: 'select' | 'multi_select' | 'status' | 'checkbox' | 'number' | 'date' | 'text' | 'url' | 'email';
57
57
  optionTagIds?: string[];
58
58
  newOptionParentPath?: string;
59
+ config?: Record<string, unknown>;
59
60
  }): Promise<{
60
61
  id: string;
61
62
  name: string;
@@ -97,9 +98,10 @@ export declare class MoxnClient {
97
98
  columns: Array<{
98
99
  id: string;
99
100
  name: string;
100
- type: 'select' | 'multi_select';
101
+ type: string;
101
102
  position: number;
102
103
  newOptionParentPath: string | null;
104
+ config?: Record<string, unknown> | null;
103
105
  options: Array<{
104
106
  tagId: string;
105
107
  tagName: string;
@@ -114,13 +116,21 @@ export declare class MoxnClient {
114
116
  properties: Record<string, {
115
117
  columnId: string;
116
118
  columnName: string;
117
- columnType: 'select' | 'multi_select';
119
+ columnType: string;
118
120
  values: Array<{
119
121
  tagId: string;
120
122
  tagName: string;
121
123
  tagPath: string;
122
124
  tagColor: string | null;
123
125
  }>;
126
+ } | {
127
+ columnId: string;
128
+ columnName: string;
129
+ columnType: string;
130
+ textValue: string | null;
131
+ numberValue: number | null;
132
+ dateValue: string | null;
133
+ booleanValue: boolean | null;
124
134
  }>;
125
135
  }>;
126
136
  }>;
@@ -147,5 +157,9 @@ export declare class MoxnClient {
147
157
  id: string;
148
158
  };
149
159
  }>;
160
+ /**
161
+ * Set a scalar property value on a document in a database.
162
+ */
163
+ setPropertyValue(databaseId: string, documentId: string, columnName: string, value: string | number | boolean | null): Promise<void>;
150
164
  private isConflictError;
151
165
  }
package/dist/client.js CHANGED
@@ -470,6 +470,23 @@ export class MoxnClient {
470
470
  }
471
471
  return response.json();
472
472
  }
473
+ /**
474
+ * Set a scalar property value on a document in a database.
475
+ */
476
+ async setPropertyValue(databaseId, documentId, columnName, value) {
477
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/properties`, {
478
+ method: 'POST',
479
+ headers: {
480
+ 'Content-Type': 'application/json',
481
+ 'x-api-key': this.apiKey,
482
+ },
483
+ body: JSON.stringify({ documentId, columnName, value }),
484
+ });
485
+ if (!response.ok) {
486
+ const body = await response.json().catch(() => ({}));
487
+ throw new Error(body.error || `Failed to set property value: ${response.status}`);
488
+ }
489
+ }
473
490
  isConflictError(error) {
474
491
  return (error instanceof Error &&
475
492
  'documentId' in error &&
package/dist/index.js CHANGED
@@ -370,6 +370,9 @@ program
370
370
  for (const col of dbImport.schema.mappedColumns) {
371
371
  allPropertyTypes.add(col.notionType);
372
372
  }
373
+ for (const col of dbImport.schema.scalarColumns) {
374
+ allPropertyTypes.add(col.notionType);
375
+ }
373
376
  for (const col of dbImport.schema.unmappedColumns) {
374
377
  allPropertyTypes.add(col.notionType);
375
378
  }
@@ -560,6 +563,24 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
560
563
  console.warn(` Warning: Failed to create column "${col.notionPropertyName}": ${error instanceof Error ? error.message : error}`);
561
564
  }
562
565
  }
566
+ // 2b. Create scalar columns (number, date, text, url, email)
567
+ const scalarColumnMap = new Map();
568
+ for (const col of schema.scalarColumns) {
569
+ try {
570
+ const column = await client.addDatabaseColumn(dbId, {
571
+ name: col.notionPropertyName,
572
+ type: col.moxnType,
573
+ });
574
+ scalarColumnMap.set(col.notionPropertyName, {
575
+ columnId: column.id,
576
+ moxnType: col.moxnType,
577
+ });
578
+ console.log(` Column "${col.notionPropertyName}" (${col.moxnType})`);
579
+ }
580
+ catch (error) {
581
+ console.warn(` Warning: Failed to create scalar column "${col.notionPropertyName}": ${error instanceof Error ? error.message : error}`);
582
+ }
583
+ }
563
584
  // 3. Link entries and assign tags
564
585
  // Build a map of KB path → { documentId, branchId } from migration results
565
586
  const docByPath = new Map();
@@ -605,6 +626,35 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
605
626
  }
606
627
  }
607
628
  }
629
+ // Set scalar property values
630
+ for (const [colName, scalarVal] of values.scalarValues) {
631
+ const scalarColInfo = scalarColumnMap.get(colName);
632
+ if (!scalarColInfo)
633
+ continue;
634
+ // Determine the value to send based on type
635
+ let value = null;
636
+ switch (scalarVal.moxnType) {
637
+ case 'number':
638
+ value = scalarVal.numberValue ?? null;
639
+ break;
640
+ case 'date':
641
+ value = scalarVal.dateValue ?? null;
642
+ break;
643
+ case 'text':
644
+ case 'url':
645
+ case 'email':
646
+ value = scalarVal.textValue ?? null;
647
+ break;
648
+ }
649
+ if (value != null) {
650
+ try {
651
+ await client.setPropertyValue(dbId, docInfo.documentId, colName, value);
652
+ }
653
+ catch (error) {
654
+ console.warn(` Warning: Failed to set "${colName}" on ${docInfo.documentId}: ${error instanceof Error ? error.message : error}`);
655
+ }
656
+ }
657
+ }
608
658
  }
609
659
  catch (error) {
610
660
  console.warn(` Warning: Failed to link entry to database: ${error instanceof Error ? error.message : error}`);
@@ -129,6 +129,7 @@ export interface NotionPropertySchema {
129
129
  groups: Array<{
130
130
  id: string;
131
131
  name: string;
132
+ color?: string;
132
133
  option_ids: string[];
133
134
  }>;
134
135
  };
@@ -8,17 +8,30 @@
8
8
  */
9
9
  import type { ContentBlock, SectionInput } from '../types.js';
10
10
  import type { NotionDatabase, NotionPage } from './notion-api.js';
11
- /** Parsed Notion column that maps to a Moxn DB column. */
11
+ /** Parsed Notion column that maps to a Moxn tag-based DB column. */
12
12
  export interface MappedColumn {
13
13
  notionPropertyId: string;
14
14
  notionPropertyName: string;
15
- notionType: 'select' | 'multi_select' | 'status';
16
- moxnType: 'select' | 'multi_select';
15
+ notionType: 'select' | 'multi_select' | 'status' | 'checkbox';
16
+ moxnType: 'select' | 'multi_select' | 'status' | 'checkbox';
17
17
  options: Array<{
18
18
  notionId: string;
19
19
  name: string;
20
20
  color: string;
21
21
  }>;
22
+ /** Status group metadata (only for status columns). */
23
+ groups?: Array<{
24
+ name: string;
25
+ color: string;
26
+ optionNames: string[];
27
+ }>;
28
+ }
29
+ /** Parsed Notion column that maps to a Moxn scalar DB column. */
30
+ export interface ScalarColumn {
31
+ notionPropertyId: string;
32
+ notionPropertyName: string;
33
+ notionType: 'number' | 'date' | 'url' | 'email' | 'rich_text' | 'checkbox';
34
+ moxnType: 'number' | 'date' | 'url' | 'email' | 'text';
22
35
  }
23
36
  /** Parsed Notion column that will be rendered as text in a Properties section. */
24
37
  export interface UnmappedColumn {
@@ -31,14 +44,26 @@ export interface ParsedDatabaseSchema {
31
44
  name: string;
32
45
  description: string;
33
46
  mappedColumns: MappedColumn[];
47
+ scalarColumns: ScalarColumn[];
34
48
  unmappedColumns: UnmappedColumn[];
35
49
  }
50
+ /** Scalar value for a single database entry property. */
51
+ export interface ScalarValue {
52
+ columnName: string;
53
+ moxnType: 'number' | 'date' | 'url' | 'email' | 'text';
54
+ textValue?: string | null;
55
+ numberValue?: number | null;
56
+ dateValue?: string | null;
57
+ booleanValue?: boolean | null;
58
+ }
36
59
  /** Parsed property values for a single database entry. */
37
60
  export interface ParsedEntryValues {
38
61
  /** Document name derived from title property. */
39
62
  title: string;
40
63
  /** Mapped column values: column name → selected option names. */
41
64
  tagValues: Map<string, string[]>;
65
+ /** Scalar property values: column name → scalar value. */
66
+ scalarValues: Map<string, ScalarValue>;
42
67
  /** Unmapped property values for markdown table: property name → rendered value. */
43
68
  textValues: Map<string, string>;
44
69
  /** File attachments to include as content blocks. */
@@ -17,6 +17,7 @@ export function parseDatabaseSchema(db) {
17
17
  const name = richTextToPlain(db.title) || 'Untitled Database';
18
18
  const description = richTextToPlain(db.description);
19
19
  const mappedColumns = [];
20
+ const scalarColumns = [];
20
21
  const unmappedColumns = [];
21
22
  for (const [propName, prop] of Object.entries(db.properties)) {
22
23
  // Skip title — it becomes the document name
@@ -49,17 +50,77 @@ export function parseDatabaseSchema(db) {
49
50
  });
50
51
  }
51
52
  else if (prop.type === 'status' && prop.status) {
52
- // Status is structurally identical to select
53
+ // Status → Moxn status column with group metadata
53
54
  mappedColumns.push({
54
55
  notionPropertyId: prop.id,
55
56
  notionPropertyName: propName,
56
57
  notionType: 'status',
57
- moxnType: 'select',
58
+ moxnType: 'status',
58
59
  options: prop.status.options.map((o) => ({
59
60
  notionId: o.id,
60
61
  name: o.name,
61
62
  color: o.color,
62
63
  })),
64
+ groups: prop.status.groups?.map((g) => ({
65
+ name: g.name,
66
+ color: g.color ?? 'default',
67
+ optionNames: g.option_ids
68
+ ? g.option_ids.map((id) => prop.status.options.find((o) => o.id === id)?.name ?? id)
69
+ : [],
70
+ })),
71
+ });
72
+ }
73
+ else if (prop.type === 'checkbox') {
74
+ // Checkbox → Moxn checkbox column (tag-based 2-option select)
75
+ mappedColumns.push({
76
+ notionPropertyId: prop.id,
77
+ notionPropertyName: propName,
78
+ notionType: 'checkbox',
79
+ moxnType: 'checkbox',
80
+ options: [
81
+ { notionId: 'checked', name: 'Checked', color: 'green' },
82
+ { notionId: 'unchecked', name: 'Unchecked', color: 'gray' },
83
+ ],
84
+ });
85
+ }
86
+ else if (prop.type === 'number') {
87
+ scalarColumns.push({
88
+ notionPropertyId: prop.id,
89
+ notionPropertyName: propName,
90
+ notionType: 'number',
91
+ moxnType: 'number',
92
+ });
93
+ }
94
+ else if (prop.type === 'date') {
95
+ scalarColumns.push({
96
+ notionPropertyId: prop.id,
97
+ notionPropertyName: propName,
98
+ notionType: 'date',
99
+ moxnType: 'date',
100
+ });
101
+ }
102
+ else if (prop.type === 'url') {
103
+ scalarColumns.push({
104
+ notionPropertyId: prop.id,
105
+ notionPropertyName: propName,
106
+ notionType: 'url',
107
+ moxnType: 'url',
108
+ });
109
+ }
110
+ else if (prop.type === 'email') {
111
+ scalarColumns.push({
112
+ notionPropertyId: prop.id,
113
+ notionPropertyName: propName,
114
+ notionType: 'email',
115
+ moxnType: 'email',
116
+ });
117
+ }
118
+ else if (prop.type === 'rich_text') {
119
+ scalarColumns.push({
120
+ notionPropertyId: prop.id,
121
+ notionPropertyName: propName,
122
+ notionType: 'rich_text',
123
+ moxnType: 'text',
63
124
  });
64
125
  }
65
126
  else {
@@ -74,7 +135,7 @@ export function parseDatabaseSchema(db) {
74
135
  });
75
136
  }
76
137
  }
77
- return { name, description, mappedColumns, unmappedColumns };
138
+ return { name, description, mappedColumns, scalarColumns, unmappedColumns };
78
139
  }
79
140
  // ============================================
80
141
  // Entry Parsing
@@ -85,20 +146,38 @@ export function parseDatabaseSchema(db) {
85
146
  export function parseEntryValues(page, schema) {
86
147
  const title = getPageTitle(page);
87
148
  const tagValues = new Map();
149
+ const scalarValues = new Map();
88
150
  const textValues = new Map();
89
151
  const fileBlocks = [];
90
152
  // Build lookup by property name
91
153
  const mappedByName = new Map(schema.mappedColumns.map((c) => [c.notionPropertyName, c]));
154
+ const scalarByName = new Map(schema.scalarColumns.map((c) => [c.notionPropertyName, c]));
92
155
  const unmappedByName = new Set(schema.unmappedColumns.map((c) => c.notionPropertyName));
93
156
  for (const [propName, propValue] of Object.entries(page.properties)) {
94
157
  if (propValue.type === 'title')
95
158
  continue; // Already extracted
96
- // Check if this is a mapped column
159
+ // Check if this is a tag-based mapped column
97
160
  const mapped = mappedByName.get(propName);
98
161
  if (mapped) {
99
- const values = extractSelectValues(propValue, mapped.notionType);
100
- if (values.length > 0) {
101
- tagValues.set(propName, values);
162
+ // Checkbox uses tag-based approach: extract "Checked"/"Unchecked" option name
163
+ if (mapped.moxnType === 'checkbox') {
164
+ const isChecked = propValue.checkbox === true;
165
+ tagValues.set(propName, [isChecked ? 'Checked' : 'Unchecked']);
166
+ }
167
+ else {
168
+ const values = extractSelectValues(propValue, mapped.notionType);
169
+ if (values.length > 0) {
170
+ tagValues.set(propName, values);
171
+ }
172
+ }
173
+ continue;
174
+ }
175
+ // Check if this is a scalar column
176
+ const scalar = scalarByName.get(propName);
177
+ if (scalar) {
178
+ const extracted = extractScalarValue(propValue, scalar);
179
+ if (extracted) {
180
+ scalarValues.set(propName, extracted);
102
181
  }
103
182
  continue;
104
183
  }
@@ -147,7 +226,7 @@ export function parseEntryValues(page, schema) {
147
226
  }
148
227
  }
149
228
  }
150
- return { title, tagValues, textValues, fileBlocks };
229
+ return { title, tagValues, scalarValues, textValues, fileBlocks };
151
230
  }
152
231
  /**
153
232
  * Render unmapped properties as a markdown table section.
@@ -187,6 +266,54 @@ function extractSelectValues(propValue, notionType) {
187
266
  return [];
188
267
  }
189
268
  }
269
+ function extractScalarValue(propValue, scalarCol) {
270
+ switch (scalarCol.moxnType) {
271
+ case 'number':
272
+ if (propValue.number == null)
273
+ return null;
274
+ return {
275
+ columnName: scalarCol.notionPropertyName,
276
+ moxnType: 'number',
277
+ numberValue: propValue.number,
278
+ };
279
+ case 'date':
280
+ if (!propValue.date?.start)
281
+ return null;
282
+ return {
283
+ columnName: scalarCol.notionPropertyName,
284
+ moxnType: 'date',
285
+ dateValue: propValue.date.start,
286
+ };
287
+ case 'url':
288
+ if (!propValue.url)
289
+ return null;
290
+ return {
291
+ columnName: scalarCol.notionPropertyName,
292
+ moxnType: 'url',
293
+ textValue: propValue.url,
294
+ };
295
+ case 'email':
296
+ if (!propValue.email)
297
+ return null;
298
+ return {
299
+ columnName: scalarCol.notionPropertyName,
300
+ moxnType: 'email',
301
+ textValue: propValue.email,
302
+ };
303
+ case 'text': {
304
+ const text = richTextToPlain(propValue.rich_text ?? []);
305
+ if (!text)
306
+ return null;
307
+ return {
308
+ columnName: scalarCol.notionPropertyName,
309
+ moxnType: 'text',
310
+ textValue: text,
311
+ };
312
+ }
313
+ default:
314
+ return null;
315
+ }
316
+ }
190
317
  function renderPropertyValue(prop) {
191
318
  switch (prop.type) {
192
319
  case 'rich_text':
@@ -41,6 +41,7 @@ function makeSchema(unmappedColumns) {
41
41
  name: 'Test DB',
42
42
  description: '',
43
43
  mappedColumns: [],
44
+ scalarColumns: [],
44
45
  unmappedColumns: unmappedColumns.map((name) => ({
45
46
  notionPropertyId: `prop-${name}`,
46
47
  notionPropertyName: name,
@@ -130,17 +130,62 @@ async function createNotionDatabase(ctx, parentPageId, resolved) {
130
130
  name: opt.tagName,
131
131
  color: hexToNotionColor(opt.tagColor),
132
132
  }));
133
- if (col.type === 'select') {
134
- properties[col.name] = {
135
- type: 'select',
136
- select: { options },
137
- };
138
- }
139
- else if (col.type === 'multi_select') {
140
- properties[col.name] = {
141
- type: 'multi_select',
142
- multi_select: { options },
143
- };
133
+ switch (col.type) {
134
+ case 'select':
135
+ properties[col.name] = {
136
+ type: 'select',
137
+ select: { options },
138
+ };
139
+ break;
140
+ case 'multi_select':
141
+ properties[col.name] = {
142
+ type: 'multi_select',
143
+ multi_select: { options },
144
+ };
145
+ break;
146
+ case 'status':
147
+ // Status exports as select in Notion (status requires specific API access)
148
+ properties[col.name] = {
149
+ type: 'select',
150
+ select: { options },
151
+ };
152
+ break;
153
+ case 'checkbox':
154
+ properties[col.name] = {
155
+ type: 'checkbox',
156
+ checkbox: {},
157
+ };
158
+ break;
159
+ case 'number':
160
+ properties[col.name] = {
161
+ type: 'number',
162
+ number: { format: 'number' },
163
+ };
164
+ break;
165
+ case 'date':
166
+ properties[col.name] = {
167
+ type: 'date',
168
+ date: {},
169
+ };
170
+ break;
171
+ case 'url':
172
+ properties[col.name] = {
173
+ type: 'url',
174
+ url: {},
175
+ };
176
+ break;
177
+ case 'email':
178
+ properties[col.name] = {
179
+ type: 'email',
180
+ email: {},
181
+ };
182
+ break;
183
+ case 'text':
184
+ properties[col.name] = {
185
+ type: 'rich_text',
186
+ rich_text: {},
187
+ };
188
+ break;
144
189
  }
145
190
  }
146
191
  await sleep(RATE_LIMIT_MS);
@@ -216,19 +261,81 @@ async function createDatabaseEntries(ctx, dataSourceId, resolved) {
216
261
  };
217
262
  // Add column values
218
263
  for (const [colName, propValue] of Object.entries(doc.properties)) {
219
- if (propValue.values.length === 0)
220
- continue;
221
- if (propValue.columnType === 'select') {
222
- properties[colName] = {
223
- type: 'select',
224
- select: { name: propValue.values[0].tagName },
225
- };
264
+ // Tag-based columns (select, multi_select, status, checkbox)
265
+ if ('values' in propValue) {
266
+ if (propValue.values.length === 0)
267
+ continue;
268
+ switch (propValue.columnType) {
269
+ case 'select':
270
+ case 'status':
271
+ properties[colName] = {
272
+ type: 'select',
273
+ select: { name: propValue.values[0].tagName },
274
+ };
275
+ break;
276
+ case 'multi_select':
277
+ properties[colName] = {
278
+ type: 'multi_select',
279
+ multi_select: propValue.values.map((v) => ({ name: v.tagName })),
280
+ };
281
+ break;
282
+ case 'checkbox': {
283
+ // The first tag is the "checked" option
284
+ const isChecked = propValue.values[0]?.tagName === 'Checked';
285
+ properties[colName] = {
286
+ type: 'checkbox',
287
+ checkbox: isChecked,
288
+ };
289
+ break;
290
+ }
291
+ }
226
292
  }
227
- else if (propValue.columnType === 'multi_select') {
228
- properties[colName] = {
229
- type: 'multi_select',
230
- multi_select: propValue.values.map((v) => ({ name: v.tagName })),
231
- };
293
+ else {
294
+ // Scalar columns (number, date, text, url, email)
295
+ if (propValue.textValue == null && propValue.numberValue == null && propValue.dateValue == null && propValue.booleanValue == null)
296
+ continue;
297
+ switch (propValue.columnType) {
298
+ case 'number':
299
+ if (propValue.numberValue != null) {
300
+ properties[colName] = {
301
+ type: 'number',
302
+ number: propValue.numberValue,
303
+ };
304
+ }
305
+ break;
306
+ case 'date':
307
+ if (propValue.dateValue) {
308
+ properties[colName] = {
309
+ type: 'date',
310
+ date: { start: propValue.dateValue },
311
+ };
312
+ }
313
+ break;
314
+ case 'url':
315
+ if (propValue.textValue) {
316
+ properties[colName] = {
317
+ type: 'url',
318
+ url: propValue.textValue,
319
+ };
320
+ }
321
+ break;
322
+ case 'email':
323
+ if (propValue.textValue) {
324
+ properties[colName] = {
325
+ type: 'email',
326
+ email: propValue.textValue,
327
+ };
328
+ }
329
+ break;
330
+ case 'text':
331
+ if (propValue.textValue) {
332
+ properties[colName] = {
333
+ type: 'rich_text',
334
+ rich_text: [{ type: 'text', text: { content: propValue.textValue } }],
335
+ };
336
+ }
337
+ break;
338
+ }
232
339
  }
233
340
  }
234
341
  await sleep(RATE_LIMIT_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
4
4
  "description": "Migration tool for importing documents into Moxn Knowledge Base from local files, Notion, Google Docs, and more",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",