wldx-admin-cli 0.0.0-stage → 1.2.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/LICENSE +21 -0
- package/README.md +445 -2
- package/bin/wx-admin.mjs +5 -0
- package/package.json +36 -3
- package/src/cli.mjs +71 -0
- package/src/commands/acl.mjs +376 -0
- package/src/commands/api.mjs +66 -0
- package/src/commands/config.mjs +527 -0
- package/src/commands/device.mjs +275 -0
- package/src/commands/dialplan.mjs +488 -0
- package/src/commands/index.mjs +85 -0
- package/src/commands/pbx.mjs +197 -0
- package/src/commands/phonebook.mjs +558 -0
- package/src/commands/queue.mjs +456 -0
- package/src/commands/records.mjs +623 -0
- package/src/commands/routing.mjs +1059 -0
- package/src/commands/sound.mjs +232 -0
- package/src/commands/system.mjs +668 -0
- package/src/commands/trunk.mjs +808 -0
- package/src/commands/user.mjs +574 -0
- package/src/match.mjs +15 -0
- package/src/pbx.mjs +369 -0
- package/src/transfer/apply.mjs +109 -0
- package/src/transfer/folder.mjs +77 -0
- package/src/transfer/kinds.mjs +259 -0
- package/src/transfer/log.mjs +35 -0
- package/src/transfer/next-steps.mjs +251 -0
- package/src/transfer/plan.mjs +153 -0
- package/src/transfer/refs.mjs +251 -0
- package/src/transfer/run.mjs +308 -0
- package/src/transfer/selection.mjs +124 -0
- package/src/version.mjs +3 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
// wx-admin phonebook: the phonebooks of a PBX and their contacts.
|
|
2
|
+
// Operations (PBX REST API; Company API Key scope phonebooks):
|
|
3
|
+
// GET /Phonebooks/ list (total, records: id, name, type, owner_id, contacts_cnt…)
|
|
4
|
+
// GET /Phonebooks/{id}/ one
|
|
5
|
+
// POST /Phonebooks/ {data: {name, type}} -> {id}
|
|
6
|
+
// PUT /Phonebooks/{id}/ {data: {name?, type?}} -> {changed}
|
|
7
|
+
// DELETE /Phonebooks/{id}/ -> {phonebooks, contacts} (its contacts go with it)
|
|
8
|
+
// GET /Phonebooks/{id}/Contacts/ contacts (start, count, search; total, records)
|
|
9
|
+
// GET /Phonebooks/{id}/Contacts/?filter[id]=… one contact (…/Contacts/{contactId} answers the whole list)
|
|
10
|
+
// POST /Phonebooks/{id}/Contacts {data: {name, …}} -> {id}
|
|
11
|
+
// PUT /phonebooks/{id}/Contacts/{contactId} {data: {…}} -> {changed}
|
|
12
|
+
// DELETE /Phonebooks/{id}/Contacts/{contactId}/ -> {contacts}
|
|
13
|
+
// Every value is a string in the answers (ids too). Phonebook types: 1 personal (of its owner), 2 global (shared).
|
|
14
|
+
// The routes by phonebook are the ones the PBX lets a Company API Key use; /Contacts/{id} is not.
|
|
15
|
+
// Export and import go through the same contact routes, one contact at a time.
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import {UsageError} from 'wldx-core/errors';
|
|
18
|
+
import {pickByName} from '../match.mjs';
|
|
19
|
+
import {readForUpload} from 'wldx-core/files';
|
|
20
|
+
import {oneLine, paint, table} from 'wldx-core/format';
|
|
21
|
+
import {query} from 'wldx-core/http';
|
|
22
|
+
import {pbxClient} from '../pbx.mjs';
|
|
23
|
+
import {checkNewFile, writeNewFile} from './records.mjs';
|
|
24
|
+
|
|
25
|
+
const SCOPE = {scope: 'phonebooks'};
|
|
26
|
+
const TYPES = {personal: 1, global: 2};
|
|
27
|
+
const TYPE_NAMES = {1: 'personal', 2: 'global'};
|
|
28
|
+
|
|
29
|
+
// The fields of a contact a person writes (the rest are kept by the PBX).
|
|
30
|
+
export const CONTACT_FIELDS = [
|
|
31
|
+
'name',
|
|
32
|
+
'organization',
|
|
33
|
+
'department',
|
|
34
|
+
'email',
|
|
35
|
+
'phone',
|
|
36
|
+
'mobile',
|
|
37
|
+
'office',
|
|
38
|
+
'home',
|
|
39
|
+
'home_mobile',
|
|
40
|
+
'fax',
|
|
41
|
+
'extension',
|
|
42
|
+
'shortcut',
|
|
43
|
+
'note',
|
|
44
|
+
'address',
|
|
45
|
+
'city',
|
|
46
|
+
'postal_code',
|
|
47
|
+
'province',
|
|
48
|
+
'country',
|
|
49
|
+
'vat_id',
|
|
50
|
+
'name_prefix',
|
|
51
|
+
'type',
|
|
52
|
+
];
|
|
53
|
+
// What the PBX keeps for itself: not exported, not imported.
|
|
54
|
+
const SYSTEM_FIELDS = new Set(['id', 'phonebook_id', 'hash', 'last_update', 'source_id', 'group_id', 'owner_id']);
|
|
55
|
+
|
|
56
|
+
// The options of contact-add and contact-update: one per common field, --set for the others.
|
|
57
|
+
const CONTACT_OPTIONS = {
|
|
58
|
+
name: {type: 'string', placeholder: 'TEXT', description: 'the name shown for the contact'},
|
|
59
|
+
phone: {type: 'string', placeholder: 'NUMBER', description: 'the main phone number'},
|
|
60
|
+
mobile: {type: 'string', placeholder: 'NUMBER', description: 'the mobile number'},
|
|
61
|
+
office: {type: 'string', placeholder: 'NUMBER', description: 'the office number'},
|
|
62
|
+
home: {type: 'string', placeholder: 'NUMBER', description: 'the home number'},
|
|
63
|
+
fax: {type: 'string', placeholder: 'NUMBER', description: 'the fax number'},
|
|
64
|
+
email: {type: 'string', placeholder: 'ADDRESS', description: 'the e-mail address'},
|
|
65
|
+
organization: {type: 'string', placeholder: 'TEXT', description: 'the company'},
|
|
66
|
+
department: {type: 'string', placeholder: 'TEXT', description: 'the department'},
|
|
67
|
+
note: {type: 'string', placeholder: 'TEXT', description: 'a note'},
|
|
68
|
+
set: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
multiple: true,
|
|
71
|
+
placeholder: 'FIELD=VALUE',
|
|
72
|
+
description: `another field: ${CONTACT_FIELDS.filter((f) => !['name', 'phone', 'mobile', 'office', 'home', 'fax', 'email', 'organization', 'department', 'note'].includes(f)).join(', ')}`,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function contactData(values) {
|
|
77
|
+
const data = {};
|
|
78
|
+
for (const k of ['name', 'phone', 'mobile', 'office', 'home', 'fax', 'email', 'organization', 'department', 'note'])
|
|
79
|
+
if (values[k] !== undefined) data[k] = values[k];
|
|
80
|
+
for (const spec of values.set ?? []) {
|
|
81
|
+
const at = spec.indexOf('=');
|
|
82
|
+
const key = at > 0 ? spec.slice(0, at) : '';
|
|
83
|
+
if (!/^[a-z_]{1,40}$/.test(key) || SYSTEM_FIELDS.has(key))
|
|
84
|
+
throw new UsageError(`--set is FIELD=VALUE with a field of a contact (${CONTACT_FIELDS.join(', ')}): ${spec}`);
|
|
85
|
+
data[key] = spec.slice(at + 1);
|
|
86
|
+
}
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const idOf = (value, what) => {
|
|
91
|
+
if (!/^\d{1,12}$/.test(String(value))) throw new UsageError(`A ${what} id is a number: ${value}`);
|
|
92
|
+
return String(value);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// An id of a phonebook or a contact in a request path: digits only (it may come from a PBX answer).
|
|
96
|
+
function pid(v) {
|
|
97
|
+
const s = String(v ?? '');
|
|
98
|
+
if (!/^\d{1,12}$/.test(s)) throw new UsageError(`Not an id: ${s}`);
|
|
99
|
+
return s;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function listPhonebooks(client) {
|
|
103
|
+
return (await client.call('GET', `/Phonebooks/${query({count: 1000})}`, undefined, SCOPE)) ?? {records: []};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A phonebook by id or by name (exact, else without case).
|
|
107
|
+
async function phonebookId(client, ref) {
|
|
108
|
+
if (/^\d+$/.test(String(ref))) return String(ref);
|
|
109
|
+
const list = (await listPhonebooks(client)).records ?? [];
|
|
110
|
+
return String(pickByName(list, ref, (p) => p.name, 'phonebook', 'wx-admin phonebook list').id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Every contact of a phonebook, page by page.
|
|
114
|
+
async function allContacts(client, id) {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (let start = 0; ; start += 500) {
|
|
117
|
+
const r = await client.call(
|
|
118
|
+
'GET',
|
|
119
|
+
`/Phonebooks/${pid(id)}/Contacts/${query({start, count: 500, sort: 'id', dir: 'ASC'})}`,
|
|
120
|
+
undefined,
|
|
121
|
+
SCOPE,
|
|
122
|
+
);
|
|
123
|
+
const records = r?.records ?? [];
|
|
124
|
+
out.push(...records);
|
|
125
|
+
if (records.length < 500 || out.length >= Number(r?.total ?? 0)) return out;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const typeName = (t) => TYPE_NAMES[t] ?? `type ${t}`;
|
|
130
|
+
|
|
131
|
+
const phonebookLine = (p) =>
|
|
132
|
+
`${p.name} (id ${p.id}, ${typeName(p.type)}${p.contacts_cnt !== undefined ? `, ${p.contacts_cnt} contacts` : ''})`;
|
|
133
|
+
|
|
134
|
+
// The number a person calls first.
|
|
135
|
+
const mainNumber = (c) => c.phone || c.mobile || c.office || c.extension || c.home || c.home_mobile || '';
|
|
136
|
+
|
|
137
|
+
const filled = (c) =>
|
|
138
|
+
Object.fromEntries(Object.entries(c ?? {}).filter(([, v]) => v !== '' && v !== null && v !== undefined));
|
|
139
|
+
|
|
140
|
+
// ---- CSV (RFC 4180): fields with a comma, quote or line break are quoted ----
|
|
141
|
+
|
|
142
|
+
// A cell a spreadsheet would run as a formula (=, @, a tab, or + / - followed by more than a phone number) gets a
|
|
143
|
+
// leading ' (OWASP CSV injection); parseCsv takes it off again. Phone numbers like +39 02 1234 stay as they are.
|
|
144
|
+
const FORMULA = /^[=@\t\r]|^[+-](?![\d\s().\-+/]*$)/;
|
|
145
|
+
const csvCell = (v) => {
|
|
146
|
+
let s = String(v ?? '');
|
|
147
|
+
if (FORMULA.test(s)) s = `'${s}`;
|
|
148
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
149
|
+
};
|
|
150
|
+
const unguard = (c) => (c.startsWith("'") && FORMULA.test(c.slice(1)) ? c.slice(1) : c);
|
|
151
|
+
|
|
152
|
+
export function toCsv(rows, columns) {
|
|
153
|
+
return `${[columns.map(csvCell).join(','), ...rows.map((r) => columns.map((c) => csvCell(r[c])).join(','))].join('\r\n')}\r\n`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function parseCsv(text) {
|
|
157
|
+
const rows = [];
|
|
158
|
+
let row = [];
|
|
159
|
+
let cell = '';
|
|
160
|
+
let quoted = false;
|
|
161
|
+
const s = String(text).replace(/^/, '');
|
|
162
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
163
|
+
const ch = s[i];
|
|
164
|
+
if (quoted) {
|
|
165
|
+
if (ch === '"' && s[i + 1] === '"') {
|
|
166
|
+
cell += '"';
|
|
167
|
+
i += 1;
|
|
168
|
+
} else if (ch === '"') quoted = false;
|
|
169
|
+
else cell += ch;
|
|
170
|
+
} else if (ch === '"' && cell === '') quoted = true;
|
|
171
|
+
else if (ch === ',') {
|
|
172
|
+
row.push(cell);
|
|
173
|
+
cell = '';
|
|
174
|
+
} else if (ch === '\n' || ch === '\r') {
|
|
175
|
+
if (ch === '\r' && s[i + 1] === '\n') i += 1;
|
|
176
|
+
row.push(cell);
|
|
177
|
+
rows.push(row);
|
|
178
|
+
row = [];
|
|
179
|
+
cell = '';
|
|
180
|
+
} else cell += ch;
|
|
181
|
+
}
|
|
182
|
+
if (quoted) throw new UsageError('The CSV has a quote that is not closed');
|
|
183
|
+
if (cell !== '' || row.length) {
|
|
184
|
+
row.push(cell);
|
|
185
|
+
rows.push(row);
|
|
186
|
+
}
|
|
187
|
+
const [header, ...body] = rows.filter((r) => r.some((c) => c !== ''));
|
|
188
|
+
if (!header) return [];
|
|
189
|
+
const columns = header.map((h) => h.trim());
|
|
190
|
+
return body.map((r) => Object.fromEntries(columns.map((c, i) => [c, unguard(r[i] ?? '')]).filter(([c]) => c)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The contacts of an export: their own fields, without the ones the PBX keeps.
|
|
194
|
+
const exportable = (c) =>
|
|
195
|
+
Object.fromEntries(
|
|
196
|
+
Object.entries(c).filter(([k, v]) => !SYSTEM_FIELDS.has(k) && v !== '' && v !== null && v !== undefined),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const csvColumns = (contacts) => {
|
|
200
|
+
const seen = new Set(['name']);
|
|
201
|
+
for (const c of contacts) for (const k of Object.keys(c)) seen.add(k);
|
|
202
|
+
return [...seen];
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const formatOf = (format, file) => format ?? (file && /\.csv$/i.test(file) ? 'csv' : 'json');
|
|
206
|
+
|
|
207
|
+
async function readInput(ctx, from) {
|
|
208
|
+
if (from === '-') {
|
|
209
|
+
const chunks = [];
|
|
210
|
+
for await (const c of ctx.stdin) chunks.push(Buffer.from(c));
|
|
211
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
212
|
+
}
|
|
213
|
+
return readForUpload(path.resolve(ctx.cwd ?? process.cwd(), from), ctx.env).toString('utf8');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const contactsText = (r, ctx) => {
|
|
217
|
+
const c = paint(ctx);
|
|
218
|
+
const records = r?.records ?? [];
|
|
219
|
+
if (!records.length) return 'No contacts';
|
|
220
|
+
const shown = `${records.length} of ${r.total ?? records.length} contacts`;
|
|
221
|
+
return [
|
|
222
|
+
...table(
|
|
223
|
+
records.map((x) => [
|
|
224
|
+
x.id,
|
|
225
|
+
oneLine(x.name, 40),
|
|
226
|
+
mainNumber(x),
|
|
227
|
+
oneLine(x.email ?? '', 40),
|
|
228
|
+
oneLine(x.organization ?? '', 30),
|
|
229
|
+
]),
|
|
230
|
+
),
|
|
231
|
+
c.dim(Number(r.total) > records.length ? `${shown} (--offset for more)` : shown),
|
|
232
|
+
];
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const groups = {
|
|
236
|
+
phonebook: {
|
|
237
|
+
summary: 'phonebooks and their contacts: list, show, create, change, delete, export, import',
|
|
238
|
+
commands: {
|
|
239
|
+
list: {
|
|
240
|
+
summary: 'the phonebooks of the PBX',
|
|
241
|
+
usage: 'wx-admin phonebook list [--search TEXT]',
|
|
242
|
+
options: {search: {type: 'string', placeholder: 'TEXT', description: 'phonebooks with this text in the name'}},
|
|
243
|
+
args: [0, 0],
|
|
244
|
+
async run(ctx) {
|
|
245
|
+
return (await pbxClient(ctx)).call(
|
|
246
|
+
'GET',
|
|
247
|
+
`/Phonebooks/${query({count: 1000, search: ctx.values.search})}`,
|
|
248
|
+
undefined,
|
|
249
|
+
SCOPE,
|
|
250
|
+
);
|
|
251
|
+
},
|
|
252
|
+
text: (r) =>
|
|
253
|
+
r?.records?.length
|
|
254
|
+
? table(
|
|
255
|
+
r.records.map((p) => [
|
|
256
|
+
p.id,
|
|
257
|
+
typeName(p.type),
|
|
258
|
+
p.contacts_cnt === undefined ? '' : `${p.contacts_cnt} contacts`,
|
|
259
|
+
p.name,
|
|
260
|
+
]),
|
|
261
|
+
)
|
|
262
|
+
: 'No phonebooks',
|
|
263
|
+
},
|
|
264
|
+
get: {
|
|
265
|
+
summary: 'one phonebook',
|
|
266
|
+
usage: 'wx-admin phonebook get <phonebook>',
|
|
267
|
+
details: 'A phonebook is its id or its name.',
|
|
268
|
+
options: {},
|
|
269
|
+
args: [1, 1],
|
|
270
|
+
async run(ctx) {
|
|
271
|
+
const client = await pbxClient(ctx);
|
|
272
|
+
return client.call('GET', `/Phonebooks/${await phonebookId(client, ctx.positionals[0])}/`, undefined, SCOPE);
|
|
273
|
+
},
|
|
274
|
+
text: (p) => phonebookLine(p),
|
|
275
|
+
},
|
|
276
|
+
create: {
|
|
277
|
+
summary: 'create a phonebook',
|
|
278
|
+
usage: 'wx-admin phonebook create <name> [--type global|personal]',
|
|
279
|
+
options: {
|
|
280
|
+
type: {
|
|
281
|
+
type: 'string',
|
|
282
|
+
choices: Object.keys(TYPES),
|
|
283
|
+
description: 'global: shared by the PBX (default); personal: of the user of the access',
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
args: [1, 1],
|
|
287
|
+
async run(ctx) {
|
|
288
|
+
const name = ctx.positionals[0].trim();
|
|
289
|
+
if (!name || name.length > 255) throw new UsageError('A phonebook needs a name (up to 255 characters)');
|
|
290
|
+
const r = await (await pbxClient(ctx)).call(
|
|
291
|
+
'POST',
|
|
292
|
+
'/Phonebooks/',
|
|
293
|
+
{data: {name, type: TYPES[ctx.values.type ?? 'global']}},
|
|
294
|
+
SCOPE,
|
|
295
|
+
);
|
|
296
|
+
return {id: r?.id, name};
|
|
297
|
+
},
|
|
298
|
+
text: (r) => `Created the phonebook ${r.name} (id ${r.id})`,
|
|
299
|
+
},
|
|
300
|
+
update: {
|
|
301
|
+
summary: 'rename a phonebook or change its type',
|
|
302
|
+
usage: 'wx-admin phonebook update <phonebook> [--name NAME] [--type global|personal]',
|
|
303
|
+
options: {
|
|
304
|
+
name: {type: 'string', placeholder: 'NAME', description: 'the new name'},
|
|
305
|
+
type: {type: 'string', choices: Object.keys(TYPES), description: 'global (shared by the PBX) or personal'},
|
|
306
|
+
},
|
|
307
|
+
args: [1, 1],
|
|
308
|
+
async run(ctx) {
|
|
309
|
+
const {name, type} = ctx.values;
|
|
310
|
+
if (name === undefined && type === undefined)
|
|
311
|
+
throw new UsageError('Nothing to change: give --name or --type', ctx.usage());
|
|
312
|
+
const data = {};
|
|
313
|
+
if (name !== undefined) data.name = name.trim();
|
|
314
|
+
if (type !== undefined) data.type = TYPES[type];
|
|
315
|
+
const client = await pbxClient(ctx);
|
|
316
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
317
|
+
const r = await client.call('PUT', `/Phonebooks/${pid(id)}/`, {data}, SCOPE);
|
|
318
|
+
return {id, ...data, changed: r?.changed};
|
|
319
|
+
},
|
|
320
|
+
text: (r) => `Updated the phonebook ${r.id}${r.name ? `: ${r.name}` : ''}`,
|
|
321
|
+
},
|
|
322
|
+
delete: {
|
|
323
|
+
summary: 'delete a phonebook with all its contacts (needs --yes)',
|
|
324
|
+
usage: 'wx-admin phonebook delete <phonebook> --yes',
|
|
325
|
+
options: {yes: {type: 'boolean', description: 'yes, delete it and its contacts'}},
|
|
326
|
+
args: [1, 1],
|
|
327
|
+
async run(ctx) {
|
|
328
|
+
if (!ctx.values.yes)
|
|
329
|
+
throw new UsageError('Deleting a phonebook deletes its contacts: confirm with --yes', ctx.usage());
|
|
330
|
+
const client = await pbxClient(ctx);
|
|
331
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
332
|
+
const r = await client.call('DELETE', `/Phonebooks/${pid(id)}/`, undefined, SCOPE);
|
|
333
|
+
return {id, ...r};
|
|
334
|
+
},
|
|
335
|
+
text: (r) => `Deleted the phonebook ${r.id}${r.contacts !== undefined ? ` and ${r.contacts} contacts` : ''}`,
|
|
336
|
+
},
|
|
337
|
+
contacts: {
|
|
338
|
+
summary: 'the contacts of a phonebook',
|
|
339
|
+
usage: 'wx-admin phonebook contacts <phonebook> [--search TEXT] [--count N] [--offset N]',
|
|
340
|
+
options: {
|
|
341
|
+
search: {
|
|
342
|
+
type: 'string',
|
|
343
|
+
placeholder: 'TEXT',
|
|
344
|
+
description: 'contacts with this text in the name, a number, the e-mail or the company',
|
|
345
|
+
},
|
|
346
|
+
count: {type: 'string', placeholder: 'N', description: 'how many (default 100, at most 1000)'},
|
|
347
|
+
offset: {type: 'string', placeholder: 'N', description: 'skip this many (the next page)'},
|
|
348
|
+
},
|
|
349
|
+
args: [1, 1],
|
|
350
|
+
async run(ctx) {
|
|
351
|
+
const {values} = ctx;
|
|
352
|
+
const count = values.count === undefined ? 100 : Number(values.count);
|
|
353
|
+
if (!Number.isInteger(count) || count < 1 || count > 1000)
|
|
354
|
+
throw new UsageError(`--count is from 1 to 1000: ${values.count}`);
|
|
355
|
+
const start = values.offset === undefined ? undefined : Number(values.offset);
|
|
356
|
+
if (start !== undefined && (!Number.isInteger(start) || start < 0))
|
|
357
|
+
throw new UsageError(`--offset is a whole number: ${values.offset}`);
|
|
358
|
+
const client = await pbxClient(ctx);
|
|
359
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
360
|
+
return client.call(
|
|
361
|
+
'GET',
|
|
362
|
+
`/Phonebooks/${pid(id)}/Contacts/${query({start, count, search: values.search, sort: 'name', dir: 'ASC'})}`,
|
|
363
|
+
undefined,
|
|
364
|
+
SCOPE,
|
|
365
|
+
);
|
|
366
|
+
},
|
|
367
|
+
text: contactsText,
|
|
368
|
+
},
|
|
369
|
+
contact: {
|
|
370
|
+
summary: 'one contact of a phonebook: its filled fields',
|
|
371
|
+
usage: 'wx-admin phonebook contact <phonebook> <contactId>',
|
|
372
|
+
options: {},
|
|
373
|
+
args: [2, 2],
|
|
374
|
+
async run(ctx) {
|
|
375
|
+
const client = await pbxClient(ctx);
|
|
376
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
377
|
+
// GET /Phonebooks/{id}/Contacts/{contactId} answers the whole list (seen live): the contact is filtered by id.
|
|
378
|
+
const contact = idOf(ctx.positionals[1], 'contact');
|
|
379
|
+
const r = await client.call(
|
|
380
|
+
'GET',
|
|
381
|
+
`/Phonebooks/${pid(id)}/Contacts/${query({'filter[id]': contact})}`,
|
|
382
|
+
undefined,
|
|
383
|
+
SCOPE,
|
|
384
|
+
);
|
|
385
|
+
const found = (r?.records ?? []).find((c) => String(c.id) === contact);
|
|
386
|
+
if (!found) throw Object.assign(new Error(`No contact ${contact} in the phonebook ${id}`), {status: 404});
|
|
387
|
+
return found;
|
|
388
|
+
},
|
|
389
|
+
text: (c) =>
|
|
390
|
+
Object.entries(filled(c))
|
|
391
|
+
.filter(([k]) => !['hash', 'source_id'].includes(k))
|
|
392
|
+
.map(([k, v]) => `${k.padEnd(13)} ${oneLine(v, 200)}`),
|
|
393
|
+
},
|
|
394
|
+
'contact-add': {
|
|
395
|
+
summary: 'add a contact to a phonebook',
|
|
396
|
+
usage:
|
|
397
|
+
'wx-admin phonebook contact-add <phonebook> --name TEXT [--phone N] [--mobile N] [--email A] [--organization T] [--set FIELD=VALUE]...',
|
|
398
|
+
options: CONTACT_OPTIONS,
|
|
399
|
+
args: [1, 1],
|
|
400
|
+
async run(ctx) {
|
|
401
|
+
const data = contactData(ctx.values);
|
|
402
|
+
if (!data.name?.trim()) throw new UsageError('A contact needs --name', ctx.usage());
|
|
403
|
+
const client = await pbxClient(ctx);
|
|
404
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
405
|
+
const r = await client.call('POST', `/Phonebooks/${pid(id)}/Contacts`, {data}, SCOPE);
|
|
406
|
+
return {id: r?.id, phonebook: id, name: data.name};
|
|
407
|
+
},
|
|
408
|
+
text: (r) => `Added ${r.name} (id ${r.id}) to the phonebook ${r.phonebook}`,
|
|
409
|
+
},
|
|
410
|
+
'contact-update': {
|
|
411
|
+
summary: 'change fields of a contact (the others stay)',
|
|
412
|
+
usage:
|
|
413
|
+
'wx-admin phonebook contact-update <phonebook> <contactId> [--name TEXT] [--phone N] … [--set FIELD=VALUE]...',
|
|
414
|
+
details: 'An empty value clears a field: --email "".',
|
|
415
|
+
options: CONTACT_OPTIONS,
|
|
416
|
+
args: [2, 2],
|
|
417
|
+
async run(ctx) {
|
|
418
|
+
const data = contactData(ctx.values);
|
|
419
|
+
if (!Object.keys(data).length) throw new UsageError('Nothing to change: give a field', ctx.usage());
|
|
420
|
+
if (data.name !== undefined && !data.name.trim()) throw new UsageError('A contact cannot have an empty name');
|
|
421
|
+
const client = await pbxClient(ctx);
|
|
422
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
423
|
+
const contact = idOf(ctx.positionals[1], 'contact');
|
|
424
|
+
const r = await client.call('PUT', `/phonebooks/${pid(id)}/Contacts/${pid(contact)}`, {data}, SCOPE);
|
|
425
|
+
return {id: contact, phonebook: id, changed: r?.changed, fields: Object.keys(data)};
|
|
426
|
+
},
|
|
427
|
+
text: (r) => `Updated the contact ${r.id}: ${r.fields.join(', ')}`,
|
|
428
|
+
},
|
|
429
|
+
'contact-delete': {
|
|
430
|
+
summary: 'delete contacts of a phonebook (needs --yes)',
|
|
431
|
+
usage: 'wx-admin phonebook contact-delete <phonebook> <contactId>... --yes',
|
|
432
|
+
options: {yes: {type: 'boolean', description: 'yes, delete them'}},
|
|
433
|
+
args: [2, 101],
|
|
434
|
+
async run(ctx) {
|
|
435
|
+
if (!ctx.values.yes)
|
|
436
|
+
throw new UsageError('A deleted contact cannot be brought back: confirm with --yes', ctx.usage());
|
|
437
|
+
const ids = ctx.positionals.slice(1).map((x) => idOf(x, 'contact'));
|
|
438
|
+
const client = await pbxClient(ctx);
|
|
439
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
440
|
+
let deleted = 0;
|
|
441
|
+
for (const contact of ids)
|
|
442
|
+
deleted += Number(
|
|
443
|
+
(await client.call('DELETE', `/Phonebooks/${pid(id)}/Contacts/${pid(contact)}/`, undefined, SCOPE))
|
|
444
|
+
?.contacts ?? 1,
|
|
445
|
+
);
|
|
446
|
+
return {phonebook: id, deleted, ids};
|
|
447
|
+
},
|
|
448
|
+
text: (r) => `Deleted ${r.deleted} contact${r.deleted === 1 ? '' : 's'} of the phonebook ${r.phonebook}`,
|
|
449
|
+
},
|
|
450
|
+
export: {
|
|
451
|
+
summary: 'the contacts of a phonebook as CSV or JSON (to stdout, or to a new file)',
|
|
452
|
+
usage: 'wx-admin phonebook export <phonebook> [--to FILE] [--format csv|json]',
|
|
453
|
+
details:
|
|
454
|
+
'The format follows the file name (.csv) unless --format says it; JSON is a list of contacts. Fields the PBX keeps (id, hash…) are left out, so phonebook import takes the file.',
|
|
455
|
+
options: {
|
|
456
|
+
to: {type: 'string', placeholder: 'FILE', description: 'write it to this file (which must not exist)'},
|
|
457
|
+
format: {
|
|
458
|
+
type: 'string',
|
|
459
|
+
choices: ['csv', 'json'],
|
|
460
|
+
description: 'csv or json (default: by the file name, else json)',
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
args: [1, 1],
|
|
464
|
+
async run(ctx) {
|
|
465
|
+
if (ctx.values.to) checkNewFile(ctx, ctx.values.to);
|
|
466
|
+
const client = await pbxClient(ctx);
|
|
467
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
468
|
+
const contacts = (await allContacts(client, id)).map(exportable);
|
|
469
|
+
const format = formatOf(ctx.values.format, ctx.values.to);
|
|
470
|
+
const content =
|
|
471
|
+
format === 'csv' ? toCsv(contacts, csvColumns(contacts)) : `${JSON.stringify(contacts, null, 2)}\n`;
|
|
472
|
+
if (!ctx.values.to) return format === 'csv' ? {csv: content} : contacts;
|
|
473
|
+
return {file: writeNewFile(ctx, ctx.values.to, content), phonebook: id, contacts: contacts.length, format};
|
|
474
|
+
},
|
|
475
|
+
text: (r) =>
|
|
476
|
+
r.file
|
|
477
|
+
? `Exported ${r.contacts} contacts of the phonebook ${r.phonebook} to ${r.file}`
|
|
478
|
+
: r.csv !== undefined
|
|
479
|
+
? r.csv.replace(/\r\n$/, '').split('\r\n')
|
|
480
|
+
: JSON.stringify(r, null, 2),
|
|
481
|
+
},
|
|
482
|
+
import: {
|
|
483
|
+
summary: 'add the contacts of a CSV or JSON file to a phonebook',
|
|
484
|
+
usage: 'wx-admin phonebook import <phonebook> --from FILE|- [--format csv|json]',
|
|
485
|
+
details: [
|
|
486
|
+
'CSV: a header row of field names (name, phone, mobile, email, organization…), one contact per row.',
|
|
487
|
+
'JSON: a list of contacts ({name, phone, …}), as phonebook export writes it. Every contact needs a name.',
|
|
488
|
+
'Each contact is added (none is merged or replaced); the ones the PBX refuses are listed, the others stay.',
|
|
489
|
+
].join('\n'),
|
|
490
|
+
options: {
|
|
491
|
+
from: {type: 'string', placeholder: 'FILE', description: 'the file (or - for stdin)'},
|
|
492
|
+
format: {
|
|
493
|
+
type: 'string',
|
|
494
|
+
choices: ['csv', 'json'],
|
|
495
|
+
description: 'csv or json (default: by the file name, else json)',
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
args: [1, 1],
|
|
499
|
+
async run(ctx) {
|
|
500
|
+
if (!ctx.values.from) throw new UsageError('--from is needed', ctx.usage());
|
|
501
|
+
const text = await readInput(ctx, ctx.values.from);
|
|
502
|
+
const format = formatOf(ctx.values.format, ctx.values.from === '-' ? undefined : ctx.values.from);
|
|
503
|
+
let rows;
|
|
504
|
+
if (format === 'csv') rows = parseCsv(text);
|
|
505
|
+
else {
|
|
506
|
+
try {
|
|
507
|
+
rows = JSON.parse(text);
|
|
508
|
+
} catch (e) {
|
|
509
|
+
throw new UsageError(
|
|
510
|
+
`${ctx.values.from === '-' ? 'stdin' : ctx.values.from} is not JSON: ${e.message} (CSV? --format csv)`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (rows && !Array.isArray(rows) && Array.isArray(rows.contacts)) rows = rows.contacts;
|
|
514
|
+
if (!Array.isArray(rows) || rows.some((r) => !r || typeof r !== 'object' || Array.isArray(r)))
|
|
515
|
+
throw new UsageError('The JSON is a list of contacts ({name, phone, …})');
|
|
516
|
+
}
|
|
517
|
+
const contacts = rows.map((r) =>
|
|
518
|
+
Object.fromEntries(
|
|
519
|
+
Object.entries(r)
|
|
520
|
+
.filter(
|
|
521
|
+
([k, v]) =>
|
|
522
|
+
!SYSTEM_FIELDS.has(k) && /^[a-z_]{1,40}$/.test(k) && v !== null && v !== undefined && v !== '',
|
|
523
|
+
)
|
|
524
|
+
.map(([k, v]) => [k, String(v)]),
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
const unnamed = contacts.findIndex((c) => !c.name?.trim());
|
|
528
|
+
if (unnamed >= 0) throw new UsageError(`The contact ${unnamed + 1} has no name: nothing was imported`);
|
|
529
|
+
if (!contacts.length) throw new UsageError('No contacts in the file');
|
|
530
|
+
const client = await pbxClient(ctx);
|
|
531
|
+
const id = await phonebookId(client, ctx.positionals[0]);
|
|
532
|
+
const added = [];
|
|
533
|
+
const failed = [];
|
|
534
|
+
for (const [i, data] of contacts.entries()) {
|
|
535
|
+
if (ctx.signal.aborted) break;
|
|
536
|
+
try {
|
|
537
|
+
added.push((await client.call('POST', `/Phonebooks/${pid(id)}/Contacts`, {data}, SCOPE))?.id);
|
|
538
|
+
} catch (e) {
|
|
539
|
+
if (e.status === 401 || e.status === 403) throw e;
|
|
540
|
+
failed.push({index: i + 1, name: data.name, error: oneLine(e.message, 200)});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
phonebook: id,
|
|
545
|
+
added: added.length,
|
|
546
|
+
ids: added,
|
|
547
|
+
failed,
|
|
548
|
+
...(ctx.signal.aborted ? {stopped: true} : {}),
|
|
549
|
+
};
|
|
550
|
+
},
|
|
551
|
+
text: (r) => [
|
|
552
|
+
`Added ${r.added} contact${r.added === 1 ? '' : 's'} to the phonebook ${r.phonebook}${r.stopped ? ' (stopped)' : ''}`,
|
|
553
|
+
...r.failed.map((f) => ` not added: ${f.index} ${oneLine(f.name, 40)}: ${f.error}`),
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
};
|