@jordancoin/notioncli 1.2.1 → 1.3.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 +111 -423
- package/TECHNICAL.md +125 -0
- package/bin/notion.js +922 -857
- package/lib/config.js +68 -0
- package/lib/filters.js +213 -0
- package/lib/format.js +225 -0
- package/lib/helpers.js +9 -334
- package/lib/markdown.js +347 -0
- package/package.json +1 -1
- package/skill/SKILL.md +44 -10
- package/skill/marketplace.json +2 -2
- package/test/unit.test.js +392 -0
- package/test/debug-parent.js +0 -32
- package/test/live-relations-test.js +0 -309
package/lib/config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// lib/config.js — Config file system management
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function getConfigPaths(overrideDir) {
|
|
7
|
+
const configDir = overrideDir || path.join(
|
|
8
|
+
process.env.XDG_CONFIG_HOME || path.join(require('os').homedir(), '.config'),
|
|
9
|
+
'notioncli'
|
|
10
|
+
);
|
|
11
|
+
return {
|
|
12
|
+
CONFIG_DIR: configDir,
|
|
13
|
+
CONFIG_PATH: path.join(configDir, 'config.json'),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadConfig(configPath) {
|
|
18
|
+
let config;
|
|
19
|
+
try {
|
|
20
|
+
if (fs.existsSync(configPath)) {
|
|
21
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
22
|
+
}
|
|
23
|
+
} catch (err) {
|
|
24
|
+
// Corrupted config — start fresh
|
|
25
|
+
}
|
|
26
|
+
if (!config) config = { activeWorkspace: 'default', workspaces: { default: { aliases: {} } } };
|
|
27
|
+
return migrateConfig(config);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Migrate old flat config → multi-workspace format.
|
|
32
|
+
* Old: { apiKey, aliases }
|
|
33
|
+
* New: { activeWorkspace, workspaces: { name: { apiKey, aliases } } }
|
|
34
|
+
*/
|
|
35
|
+
function migrateConfig(config) {
|
|
36
|
+
if (config.workspaces) return config; // already migrated
|
|
37
|
+
// Old format detected — wrap in "default" workspace
|
|
38
|
+
const ws = { aliases: config.aliases || {} };
|
|
39
|
+
if (config.apiKey) ws.apiKey = config.apiKey;
|
|
40
|
+
return { activeWorkspace: 'default', workspaces: { default: ws } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the config for a specific workspace (or the active one).
|
|
45
|
+
* Returns { apiKey, aliases } for that workspace.
|
|
46
|
+
*/
|
|
47
|
+
function resolveWorkspace(config, workspaceName) {
|
|
48
|
+
const name = workspaceName || config.activeWorkspace || 'default';
|
|
49
|
+
const ws = config.workspaces && config.workspaces[name];
|
|
50
|
+
if (!ws) {
|
|
51
|
+
const available = config.workspaces ? Object.keys(config.workspaces) : [];
|
|
52
|
+
return { error: `Unknown workspace: "${name}"`, available, name };
|
|
53
|
+
}
|
|
54
|
+
return { apiKey: ws.apiKey, aliases: ws.aliases || {}, name };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveConfig(config, configDir, configPath) {
|
|
58
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
59
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
getConfigPaths,
|
|
64
|
+
loadConfig,
|
|
65
|
+
migrateConfig,
|
|
66
|
+
resolveWorkspace,
|
|
67
|
+
saveConfig,
|
|
68
|
+
};
|
package/lib/filters.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// lib/filters.js — Filter parsing and building for Notion API queries
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a filter string into { key, operator, value }.
|
|
5
|
+
* Supports: >=, <=, !=, >, <, = (default)
|
|
6
|
+
* Examples: "Status=Active", "Day>5", "Date>=2026-01-01", "Name!=Draft"
|
|
7
|
+
*/
|
|
8
|
+
function parseFilterOperator(filterStr) {
|
|
9
|
+
// Order matters: check multi-char operators first
|
|
10
|
+
const ops = ['>=', '<=', '!=', '>', '<', '='];
|
|
11
|
+
for (const op of ops) {
|
|
12
|
+
const idx = filterStr.indexOf(op);
|
|
13
|
+
if (idx > 0) {
|
|
14
|
+
return {
|
|
15
|
+
key: filterStr.slice(0, idx),
|
|
16
|
+
operator: op,
|
|
17
|
+
value: filterStr.slice(idx + op.length),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { error: `Invalid filter format: ${filterStr} (expected key=value, key>value, etc.)` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve relative date keywords to ISO date strings.
|
|
26
|
+
*/
|
|
27
|
+
function resolveRelativeDate(value) {
|
|
28
|
+
const lower = value.toLowerCase();
|
|
29
|
+
const now = new Date();
|
|
30
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
31
|
+
|
|
32
|
+
switch (lower) {
|
|
33
|
+
case 'today':
|
|
34
|
+
return today.toISOString().split('T')[0];
|
|
35
|
+
case 'yesterday': {
|
|
36
|
+
const d = new Date(today); d.setDate(d.getDate() - 1);
|
|
37
|
+
return d.toISOString().split('T')[0];
|
|
38
|
+
}
|
|
39
|
+
case 'tomorrow': {
|
|
40
|
+
const d = new Date(today); d.setDate(d.getDate() + 1);
|
|
41
|
+
return d.toISOString().split('T')[0];
|
|
42
|
+
}
|
|
43
|
+
case 'last_week': case 'last-week': {
|
|
44
|
+
const d = new Date(today); d.setDate(d.getDate() - 7);
|
|
45
|
+
return d.toISOString().split('T')[0];
|
|
46
|
+
}
|
|
47
|
+
case 'next_week': case 'next-week': {
|
|
48
|
+
const d = new Date(today); d.setDate(d.getDate() + 7);
|
|
49
|
+
return d.toISOString().split('T')[0];
|
|
50
|
+
}
|
|
51
|
+
default:
|
|
52
|
+
return value; // Return as-is if not a keyword
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Map { operator, type } to Notion filter condition.
|
|
58
|
+
*/
|
|
59
|
+
function operatorToCondition(type, operator, value) {
|
|
60
|
+
// Resolve relative dates
|
|
61
|
+
if (type === 'date') {
|
|
62
|
+
value = resolveRelativeDate(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Number coercion
|
|
66
|
+
if (type === 'number') {
|
|
67
|
+
value = Number(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Checkbox coercion
|
|
71
|
+
if (type === 'checkbox') {
|
|
72
|
+
value = value === 'true' || value === '1' || value === 'yes';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Map operators to Notion API condition names
|
|
76
|
+
const conditionMap = {
|
|
77
|
+
'=': getDefaultCondition(type, value),
|
|
78
|
+
'!=': getNotEqualCondition(type, value),
|
|
79
|
+
'>': { [getFilterType(type)]: { after: type === 'date' ? value : undefined, greater_than: type !== 'date' ? value : undefined } },
|
|
80
|
+
'<': { [getFilterType(type)]: { before: type === 'date' ? value : undefined, less_than: type !== 'date' ? value : undefined } },
|
|
81
|
+
'>=': { [getFilterType(type)]: { on_or_after: type === 'date' ? value : undefined, greater_than_or_equal_to: type !== 'date' ? value : undefined } },
|
|
82
|
+
'<=': { [getFilterType(type)]: { on_or_before: type === 'date' ? value : undefined, less_than_or_equal_to: type !== 'date' ? value : undefined } },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const condition = conditionMap[operator];
|
|
86
|
+
if (!condition) return null;
|
|
87
|
+
|
|
88
|
+
// Clean undefined values
|
|
89
|
+
const filterType = getFilterType(type);
|
|
90
|
+
if (condition[filterType]) {
|
|
91
|
+
const inner = condition[filterType];
|
|
92
|
+
for (const k of Object.keys(inner)) {
|
|
93
|
+
if (inner[k] === undefined) delete inner[k];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return condition;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Get the Notion filter type key for a schema type */
|
|
101
|
+
function getFilterType(type) {
|
|
102
|
+
// Most types use themselves as the filter key
|
|
103
|
+
return type;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Default equals/contains condition for = operator */
|
|
107
|
+
function getDefaultCondition(type, value) {
|
|
108
|
+
switch (type) {
|
|
109
|
+
case 'title':
|
|
110
|
+
case 'rich_text':
|
|
111
|
+
return { [type]: { contains: value } };
|
|
112
|
+
case 'select':
|
|
113
|
+
return { select: { equals: value } };
|
|
114
|
+
case 'multi_select':
|
|
115
|
+
return { multi_select: { contains: value } };
|
|
116
|
+
case 'number':
|
|
117
|
+
return { number: { equals: value } };
|
|
118
|
+
case 'checkbox':
|
|
119
|
+
return { checkbox: { equals: value } };
|
|
120
|
+
case 'date':
|
|
121
|
+
return { date: { equals: value } };
|
|
122
|
+
case 'status':
|
|
123
|
+
return { status: { equals: value } };
|
|
124
|
+
default:
|
|
125
|
+
return { [type]: { equals: value } };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Not-equal condition for != operator */
|
|
130
|
+
function getNotEqualCondition(type, value) {
|
|
131
|
+
switch (type) {
|
|
132
|
+
case 'title':
|
|
133
|
+
case 'rich_text':
|
|
134
|
+
return { [type]: { does_not_contain: value } };
|
|
135
|
+
case 'select':
|
|
136
|
+
return { select: { does_not_equal: value } };
|
|
137
|
+
case 'multi_select':
|
|
138
|
+
return { multi_select: { does_not_contain: value } };
|
|
139
|
+
case 'number':
|
|
140
|
+
return { number: { does_not_equal: value } };
|
|
141
|
+
case 'checkbox':
|
|
142
|
+
return { checkbox: { does_not_equal: value } };
|
|
143
|
+
case 'date':
|
|
144
|
+
// Notion doesn't have date does_not_equal; skip
|
|
145
|
+
return { date: { does_not_equal: value } };
|
|
146
|
+
case 'status':
|
|
147
|
+
return { status: { does_not_equal: value } };
|
|
148
|
+
default:
|
|
149
|
+
return { [type]: { does_not_equal: value } };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build a Notion filter object from a schema and a single filter string.
|
|
155
|
+
* Supports operators: =, !=, >, <, >=, <=
|
|
156
|
+
* Supports relative dates: today, yesterday, tomorrow, last_week, next_week
|
|
157
|
+
*/
|
|
158
|
+
function buildFilterFromSchema(schema, filterStr) {
|
|
159
|
+
const parsed = parseFilterOperator(filterStr);
|
|
160
|
+
if (parsed.error) return parsed;
|
|
161
|
+
|
|
162
|
+
const { key, operator, value } = parsed;
|
|
163
|
+
const schemaEntry = schema[key.toLowerCase()];
|
|
164
|
+
if (!schemaEntry) {
|
|
165
|
+
return {
|
|
166
|
+
error: `Filter property "${key}" not found in database schema.`,
|
|
167
|
+
available: Object.values(schema).map(s => s.name),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const propName = schemaEntry.name;
|
|
172
|
+
const type = schemaEntry.type;
|
|
173
|
+
|
|
174
|
+
const condition = operatorToCondition(type, operator, value);
|
|
175
|
+
if (!condition) {
|
|
176
|
+
return { error: `Operator "${operator}" not supported for type "${type}"` };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { filter: { property: propName, ...condition } };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build a compound AND filter from multiple filter strings.
|
|
184
|
+
* Each filter is parsed independently, then combined with AND.
|
|
185
|
+
*/
|
|
186
|
+
function buildCompoundFilter(schema, filterStrs) {
|
|
187
|
+
if (!Array.isArray(filterStrs) || filterStrs.length === 0) {
|
|
188
|
+
return { error: 'No filters provided' };
|
|
189
|
+
}
|
|
190
|
+
if (filterStrs.length === 1) {
|
|
191
|
+
return buildFilterFromSchema(schema, filterStrs[0]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const filters = [];
|
|
195
|
+
for (const f of filterStrs) {
|
|
196
|
+
const result = buildFilterFromSchema(schema, f);
|
|
197
|
+
if (result.error) return result;
|
|
198
|
+
filters.push(result.filter);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { filter: { and: filters } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
parseFilterOperator,
|
|
206
|
+
resolveRelativeDate,
|
|
207
|
+
operatorToCondition,
|
|
208
|
+
getDefaultCondition,
|
|
209
|
+
getNotEqualCondition,
|
|
210
|
+
getFilterType,
|
|
211
|
+
buildFilterFromSchema,
|
|
212
|
+
buildCompoundFilter,
|
|
213
|
+
};
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// lib/format.js — Output formatting (tables, CSV, YAML, property values)
|
|
2
|
+
|
|
3
|
+
/** UUID regex pattern used for validation */
|
|
4
|
+
const UUID_REGEX = /^[0-9a-f-]{32,36}$/i;
|
|
5
|
+
|
|
6
|
+
/** Extract plain text from rich_text array */
|
|
7
|
+
function richTextToPlain(rt) {
|
|
8
|
+
if (!rt) return '';
|
|
9
|
+
if (Array.isArray(rt)) return rt.map(r => r.plain_text || '').join('');
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Extract a readable value from a Notion property */
|
|
14
|
+
function propValue(prop) {
|
|
15
|
+
if (!prop) return '';
|
|
16
|
+
switch (prop.type) {
|
|
17
|
+
case 'title':
|
|
18
|
+
return richTextToPlain(prop.title);
|
|
19
|
+
case 'rich_text':
|
|
20
|
+
return richTextToPlain(prop.rich_text);
|
|
21
|
+
case 'number':
|
|
22
|
+
return prop.number != null ? String(prop.number) : '';
|
|
23
|
+
case 'select':
|
|
24
|
+
return prop.select ? prop.select.name : '';
|
|
25
|
+
case 'multi_select':
|
|
26
|
+
return (prop.multi_select || []).map(s => s.name).join(', ');
|
|
27
|
+
case 'date':
|
|
28
|
+
if (!prop.date) return '';
|
|
29
|
+
return prop.date.end ? `${prop.date.start} → ${prop.date.end}` : prop.date.start;
|
|
30
|
+
case 'checkbox':
|
|
31
|
+
return prop.checkbox ? '✓' : '✗';
|
|
32
|
+
case 'url':
|
|
33
|
+
return prop.url || '';
|
|
34
|
+
case 'email':
|
|
35
|
+
return prop.email || '';
|
|
36
|
+
case 'phone_number':
|
|
37
|
+
return prop.phone_number || '';
|
|
38
|
+
case 'status':
|
|
39
|
+
return prop.status ? prop.status.name : '';
|
|
40
|
+
case 'formula':
|
|
41
|
+
if (prop.formula) {
|
|
42
|
+
const f = prop.formula;
|
|
43
|
+
return f.string || f.number?.toString() || f.boolean?.toString() || f.date?.start || '';
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
case 'relation': {
|
|
47
|
+
const rels = prop.relation || [];
|
|
48
|
+
if (rels.length === 0) return '';
|
|
49
|
+
if (rels.length === 1) return `→ ${rels[0].id.slice(0, 8)}…`;
|
|
50
|
+
return `→ ${rels.length} linked`;
|
|
51
|
+
}
|
|
52
|
+
case 'rollup': {
|
|
53
|
+
const r = prop.rollup;
|
|
54
|
+
if (!r) return '';
|
|
55
|
+
switch (r.type) {
|
|
56
|
+
case 'number': return r.number != null ? String(r.number) : '';
|
|
57
|
+
case 'date': return r.date ? (r.date.end ? `${r.date.start} → ${r.date.end}` : r.date.start) : '';
|
|
58
|
+
case 'array': {
|
|
59
|
+
if (!r.array || r.array.length === 0) return '';
|
|
60
|
+
return r.array.map(item => propValue(item)).join(', ');
|
|
61
|
+
}
|
|
62
|
+
default: return JSON.stringify(r);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
case 'people':
|
|
66
|
+
return (prop.people || []).map(p => p.name || p.id).join(', ');
|
|
67
|
+
case 'files':
|
|
68
|
+
return (prop.files || []).map(f => f.name || f.external?.url || '').join(', ');
|
|
69
|
+
case 'created_time':
|
|
70
|
+
return prop.created_time || '';
|
|
71
|
+
case 'last_edited_time':
|
|
72
|
+
return prop.last_edited_time || '';
|
|
73
|
+
case 'created_by':
|
|
74
|
+
return prop.created_by?.name || prop.created_by?.id || '';
|
|
75
|
+
case 'last_edited_by':
|
|
76
|
+
return prop.last_edited_by?.name || prop.last_edited_by?.id || '';
|
|
77
|
+
default:
|
|
78
|
+
return JSON.stringify(prop[prop.type] ?? '');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build property value for Notion API based on schema type */
|
|
83
|
+
function buildPropValue(type, value) {
|
|
84
|
+
switch (type) {
|
|
85
|
+
case 'title':
|
|
86
|
+
return { title: [{ text: { content: value } }] };
|
|
87
|
+
case 'rich_text':
|
|
88
|
+
return { rich_text: [{ text: { content: value } }] };
|
|
89
|
+
case 'number':
|
|
90
|
+
return { number: Number(value) };
|
|
91
|
+
case 'select':
|
|
92
|
+
return { select: { name: value } };
|
|
93
|
+
case 'multi_select':
|
|
94
|
+
return { multi_select: value.split(',').map(v => ({ name: v.trim() })) };
|
|
95
|
+
case 'date':
|
|
96
|
+
return { date: { start: value } };
|
|
97
|
+
case 'checkbox':
|
|
98
|
+
return { checkbox: value === 'true' || value === '1' || value === 'yes' };
|
|
99
|
+
case 'url':
|
|
100
|
+
return { url: value };
|
|
101
|
+
case 'email':
|
|
102
|
+
return { email: value };
|
|
103
|
+
case 'phone_number':
|
|
104
|
+
return { phone_number: value };
|
|
105
|
+
case 'status':
|
|
106
|
+
return { status: { name: value } };
|
|
107
|
+
default:
|
|
108
|
+
return { [type]: value };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Simple table formatter */
|
|
113
|
+
function printTable(rows, columns) {
|
|
114
|
+
if (!rows || rows.length === 0) {
|
|
115
|
+
console.log('(no results)');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const widths = {};
|
|
120
|
+
for (const col of columns) {
|
|
121
|
+
widths[col] = col.length;
|
|
122
|
+
}
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
for (const col of columns) {
|
|
125
|
+
const val = String(row[col] ?? '');
|
|
126
|
+
widths[col] = Math.max(widths[col], val.length);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const col of columns) {
|
|
130
|
+
widths[col] = Math.min(widths[col], 50);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const header = columns.map(c => c.padEnd(widths[c])).join(' │ ');
|
|
134
|
+
const separator = columns.map(c => '─'.repeat(widths[c])).join('─┼─');
|
|
135
|
+
console.log(header);
|
|
136
|
+
console.log(separator);
|
|
137
|
+
|
|
138
|
+
for (const row of rows) {
|
|
139
|
+
const line = columns.map(c => {
|
|
140
|
+
let val = String(row[c] ?? '');
|
|
141
|
+
if (val.length > 50) val = val.slice(0, 47) + '...';
|
|
142
|
+
return val.padEnd(widths[c]);
|
|
143
|
+
}).join(' │ ');
|
|
144
|
+
console.log(line);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(`\n${rows.length} result${rows.length !== 1 ? 's' : ''}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Convert pages to table rows */
|
|
151
|
+
function pagesToRows(pages) {
|
|
152
|
+
return pages.map(page => {
|
|
153
|
+
const row = { id: page.id };
|
|
154
|
+
if (page.properties) {
|
|
155
|
+
for (const [key, prop] of Object.entries(page.properties)) {
|
|
156
|
+
row[key] = propValue(prop);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return row;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Format rows as CSV string */
|
|
164
|
+
function formatCsv(rows, columns) {
|
|
165
|
+
if (!rows || rows.length === 0) return '(no results)';
|
|
166
|
+
const escape = (val) => {
|
|
167
|
+
const s = String(val ?? '');
|
|
168
|
+
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
169
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
170
|
+
}
|
|
171
|
+
return s;
|
|
172
|
+
};
|
|
173
|
+
const lines = [columns.map(escape).join(',')];
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
lines.push(columns.map(c => escape(row[c])).join(','));
|
|
176
|
+
}
|
|
177
|
+
return lines.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Format rows as YAML string */
|
|
181
|
+
function formatYaml(rows, columns) {
|
|
182
|
+
if (!rows || rows.length === 0) return '(no results)';
|
|
183
|
+
const lines = [];
|
|
184
|
+
for (let i = 0; i < rows.length; i++) {
|
|
185
|
+
if (i > 0) lines.push('');
|
|
186
|
+
lines.push(`- # result ${i + 1}`);
|
|
187
|
+
for (const col of columns) {
|
|
188
|
+
const val = String(rows[i][col] ?? '');
|
|
189
|
+
const needsQuote = val.includes(':') || val.includes('#') || val.includes('"') || val.includes("'") || val.includes('\n') || val === '';
|
|
190
|
+
lines.push(` ${col}: ${needsQuote ? '"' + val.replace(/"/g, '\\"') + '"' : val}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Output rows in the specified format */
|
|
197
|
+
function outputFormatted(rows, columns, format) {
|
|
198
|
+
switch (format) {
|
|
199
|
+
case 'csv':
|
|
200
|
+
console.log(formatCsv(rows, columns));
|
|
201
|
+
break;
|
|
202
|
+
case 'yaml':
|
|
203
|
+
console.log(formatYaml(rows, columns));
|
|
204
|
+
break;
|
|
205
|
+
case 'json':
|
|
206
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
207
|
+
break;
|
|
208
|
+
case 'table':
|
|
209
|
+
default:
|
|
210
|
+
printTable(rows, columns);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
UUID_REGEX,
|
|
217
|
+
richTextToPlain,
|
|
218
|
+
propValue,
|
|
219
|
+
buildPropValue,
|
|
220
|
+
printTable,
|
|
221
|
+
pagesToRows,
|
|
222
|
+
formatCsv,
|
|
223
|
+
formatYaml,
|
|
224
|
+
outputFormatted,
|
|
225
|
+
};
|