@falldownthesystem/devtool 0.5.0 → 0.6.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 +167 -9
- package/package.json +7 -3
- package/src/clients/bitbucket.js +22 -26
- package/src/clients/confluence.js +170 -171
- package/src/clients/graph-auth.js +110 -0
- package/src/clients/graph-cache.js +148 -0
- package/src/clients/graph.js +263 -0
- package/src/clients/jira.js +20 -20
- package/src/clients/serviceDesk.js +33 -37
- package/src/commands/config.js +71 -27
- package/src/commands/confluence.js +8 -7
- package/src/commands/graph.js +147 -0
- package/src/commands/jira.js +6 -5
- package/src/commands/pr.js +6 -22
- package/src/commands/sd.js +8 -7
- package/src/config/setup.js +113 -0
- package/src/config/store.js +207 -171
- package/src/formatters/graph.js +220 -0
- package/src/index.js +9 -20
- package/src/utils/connection.js +5 -0
package/src/config/store.js
CHANGED
|
@@ -1,213 +1,249 @@
|
|
|
1
1
|
import Conf from 'conf';
|
|
2
|
-
import * as p from '@clack/prompts';
|
|
3
|
-
import pc from 'picocolors';
|
|
4
2
|
|
|
5
|
-
export
|
|
3
|
+
export function validateGraphId(value) {
|
|
4
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value?.trim() || '')
|
|
5
|
+
? undefined
|
|
6
|
+
: 'Enter the ID as a UUID from the Entra application Overview page.';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function validateGraphSite(value) {
|
|
10
|
+
if (!value?.trim()) return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value.trim());
|
|
13
|
+
if (url.protocol === 'https:' && url.hostname.endsWith('.sharepoint.com') && !url.username && !url.password && !url.port && !url.search && !url.hash) {
|
|
14
|
+
decodeURIComponent(url.pathname);
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
} catch {}
|
|
18
|
+
return 'Enter a SharePoint Online site URL without a query or fragment.';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A field's `validate` returns a problem message for a non-empty value; `normalize` runs on every save.
|
|
22
|
+
export const types = {
|
|
6
23
|
bitbucket: {
|
|
7
|
-
type: 'object',
|
|
8
24
|
title: 'Bitbucket',
|
|
9
|
-
|
|
10
|
-
workspace: {
|
|
11
|
-
defaultRepo: {
|
|
12
|
-
email: {
|
|
13
|
-
token: {
|
|
25
|
+
fields: {
|
|
26
|
+
workspace: { title: 'Workspace', message: 'Bitbucket workspace slug', placeholder: 'my-workspace', required: true },
|
|
27
|
+
defaultRepo: { title: 'Default repo', message: 'Bitbucket default repo slug', placeholder: 'my-repo' },
|
|
28
|
+
email: { title: 'Email', message: 'Bitbucket account email', placeholder: 'you@company.com', required: true },
|
|
29
|
+
token: { title: 'Token', message: 'Bitbucket API token', secret: true, required: true },
|
|
14
30
|
},
|
|
15
31
|
},
|
|
16
32
|
jira: {
|
|
17
|
-
type: 'object',
|
|
18
33
|
title: 'Jira',
|
|
19
|
-
|
|
20
|
-
baseUrl: {
|
|
21
|
-
token: {
|
|
22
|
-
defaultProject: {
|
|
34
|
+
fields: {
|
|
35
|
+
baseUrl: { title: 'Base URL', message: 'Jira Server base URL', placeholder: 'https://jira.company.com', required: true },
|
|
36
|
+
token: { title: 'Token', message: 'Jira Personal Access Token', secret: true, required: true },
|
|
37
|
+
defaultProject: { title: 'Default project', message: 'Jira default project key', placeholder: 'PROJ' },
|
|
23
38
|
},
|
|
24
39
|
},
|
|
25
40
|
confluence: {
|
|
26
|
-
type: 'object',
|
|
27
41
|
title: 'Confluence',
|
|
28
|
-
|
|
29
|
-
baseUrl: {
|
|
30
|
-
email: {
|
|
31
|
-
token: {
|
|
32
|
-
defaultSpace: {
|
|
42
|
+
fields: {
|
|
43
|
+
baseUrl: { title: 'Base URL', message: 'Confluence base URL (Cloud or Server/Data Center)', placeholder: 'https://company.atlassian.net', required: true },
|
|
44
|
+
email: { title: 'Email', message: 'Confluence account email (Server/Data Center: username, or empty for a personal access token)', placeholder: 'you@company.com' },
|
|
45
|
+
token: { title: 'Token', message: 'Confluence API token (Server/Data Center: personal access token or password)', secret: true, required: true },
|
|
46
|
+
defaultSpace: { title: 'Default space', message: 'Confluence default space key', placeholder: 'DEV' },
|
|
33
47
|
},
|
|
34
48
|
},
|
|
35
49
|
serviceDesk: {
|
|
36
|
-
type: 'object',
|
|
37
50
|
title: 'Service Desk',
|
|
38
|
-
|
|
39
|
-
baseUrl: {
|
|
40
|
-
email: {
|
|
41
|
-
token: {
|
|
42
|
-
defaultProject: {
|
|
51
|
+
fields: {
|
|
52
|
+
baseUrl: { title: 'Base URL', message: 'Jira Service Desk Cloud base URL (use canonical *.atlassian.net, not a vanity domain)', placeholder: 'https://company.atlassian.net', required: true },
|
|
53
|
+
email: { title: 'Email', message: 'Service Desk account email', placeholder: 'you@company.com', required: true },
|
|
54
|
+
token: { title: 'Token', message: 'Service Desk API token', secret: true, required: true },
|
|
55
|
+
defaultProject: { title: 'Default project', message: 'Service Desk default project key', placeholder: 'TUKI' },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
graph: {
|
|
59
|
+
title: 'Microsoft Graph',
|
|
60
|
+
note: 'Teams, email, calendars, and SharePoint share this application. No client secret is needed.\nRegister http://localhost under Mobile and desktop applications.\nDelegated permissions: User.Read, Chat.Read, Mail.Read, Sites.Read.All.\nOptional: Calendars.Read.Shared and Calendars.ReadBasic for calendars/free-busy; Mail.Read.Shared for shared email.\nAfter saving, run dev graph login (--all for one sign-in with every supported permission).',
|
|
61
|
+
fields: {
|
|
62
|
+
tenantId: { title: 'Tenant ID', message: 'Directory (tenant) ID', required: true, validate: validateGraphId, normalize: (v) => v.trim().toLowerCase() },
|
|
63
|
+
clientId: { title: 'Client ID', message: 'Application (client) ID', required: true, validate: validateGraphId, normalize: (v) => v.trim().toLowerCase() },
|
|
64
|
+
defaultSite: { title: 'Default site', message: 'Default SharePoint site URL (optional)', placeholder: 'https://company.sharepoint.com/sites/MySite', validate: validateGraphSite, normalize: (v) => v.trim().replace(/\/+$/, '') },
|
|
43
65
|
},
|
|
44
66
|
},
|
|
45
67
|
};
|
|
46
68
|
|
|
69
|
+
export function fieldProblem(type, key, value) {
|
|
70
|
+
const field = types[type].fields[key];
|
|
71
|
+
return value && field.validate ? field.validate(value) : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
const config = new Conf({
|
|
48
75
|
projectName: 'devtool',
|
|
49
|
-
schema
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
76
|
+
schema: {
|
|
77
|
+
connections: {
|
|
78
|
+
type: 'array',
|
|
79
|
+
items: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
type: { type: 'string', enum: Object.keys(types) },
|
|
83
|
+
name: { type: 'string' },
|
|
84
|
+
default: { type: 'boolean' },
|
|
85
|
+
},
|
|
86
|
+
required: ['type'],
|
|
87
|
+
additionalProperties: { type: 'string' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
55
90
|
},
|
|
91
|
+
defaults: { connections: [] },
|
|
56
92
|
});
|
|
57
93
|
|
|
58
|
-
|
|
59
|
-
|
|
94
|
+
migrateLegacySections();
|
|
95
|
+
|
|
96
|
+
// Older versions stored exactly one connection per type as a top-level section.
|
|
97
|
+
function migrateLegacySections() {
|
|
98
|
+
const { connections = [], ...rest } = config.store;
|
|
99
|
+
const legacy = Object.keys(rest).filter((key) => types[key]);
|
|
100
|
+
if (!legacy.length) return;
|
|
101
|
+
for (const type of legacy) {
|
|
102
|
+
const connection = clean({ type, ...rest[type] });
|
|
103
|
+
const hasValues = Object.keys(connection).length > 1;
|
|
104
|
+
if (hasValues && !connections.some((c) => c.type === type && !c.name)) connections.push(connection);
|
|
105
|
+
delete rest[type];
|
|
106
|
+
}
|
|
107
|
+
config.store = { ...rest, connections };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function clean(connection) {
|
|
111
|
+
const result = { type: connection.type };
|
|
112
|
+
if (connection.name) result.name = connection.name;
|
|
113
|
+
if (connection.default) result.default = true;
|
|
114
|
+
for (const [key, field] of Object.entries(types[connection.type].fields)) {
|
|
115
|
+
if (typeof connection[key] !== 'string') continue;
|
|
116
|
+
const value = field.normalize ? field.normalize(connection[key]) : connection[key];
|
|
117
|
+
if (value !== '') result[key] = value;
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
60
120
|
}
|
|
61
121
|
|
|
62
|
-
|
|
63
|
-
config.set(
|
|
122
|
+
function write(connections) {
|
|
123
|
+
config.set('connections', connections.map(clean));
|
|
64
124
|
}
|
|
65
125
|
|
|
66
|
-
|
|
67
|
-
|
|
126
|
+
// An unnamed connection is addressed by its type, so labels stay unique across all connections.
|
|
127
|
+
export function label(connection) {
|
|
128
|
+
return connection.name || connection.type;
|
|
68
129
|
}
|
|
69
130
|
|
|
70
|
-
export function
|
|
71
|
-
|
|
72
|
-
const jira = config.get('jira');
|
|
73
|
-
return !!(bb.workspace && bb.token && jira.baseUrl && jira.token);
|
|
131
|
+
export function listConnections() {
|
|
132
|
+
return config.get('connections');
|
|
74
133
|
}
|
|
75
134
|
|
|
76
|
-
|
|
77
|
-
|
|
135
|
+
function defaultIndex(connections, type) {
|
|
136
|
+
const flagged = connections.findIndex((c) => c.type === type && c.default);
|
|
137
|
+
return flagged !== -1 ? flagged : connections.findIndex((c) => c.type === type);
|
|
78
138
|
}
|
|
79
139
|
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}, {
|
|
107
|
-
onCancel: () => {
|
|
108
|
-
p.cancel('Setup cancelled.');
|
|
109
|
-
process.exit(0);
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const jira = await p.group({
|
|
114
|
-
baseUrl: () => p.text({
|
|
115
|
-
message: 'Jira Server base URL',
|
|
116
|
-
placeholder: 'https://jira.company.com',
|
|
117
|
-
initialValue: config.get('jira.baseUrl') || '',
|
|
118
|
-
validate: (v) => !v ? 'Required' : undefined,
|
|
119
|
-
}),
|
|
120
|
-
token: () => p.password({
|
|
121
|
-
message: 'Jira Personal Access Token',
|
|
122
|
-
validate: (v) => !v ? 'Required' : undefined,
|
|
123
|
-
}),
|
|
124
|
-
defaultProject: () => p.text({
|
|
125
|
-
message: 'Jira default project key',
|
|
126
|
-
placeholder: 'PROJ',
|
|
127
|
-
initialValue: config.get('jira.defaultProject') || '',
|
|
128
|
-
validate: (v) => !v ? 'Required' : undefined,
|
|
129
|
-
}),
|
|
130
|
-
}, {
|
|
131
|
-
onCancel: () => {
|
|
132
|
-
p.cancel('Setup cancelled.');
|
|
133
|
-
process.exit(0);
|
|
134
|
-
},
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
const confluence = await p.group({
|
|
138
|
-
baseUrl: () => p.text({
|
|
139
|
-
message: 'Confluence base URL (Cloud or Server/Data Center)',
|
|
140
|
-
placeholder: 'https://company.atlassian.net',
|
|
141
|
-
initialValue: config.get('confluence.baseUrl') || '',
|
|
142
|
-
}),
|
|
143
|
-
email: () => p.text({
|
|
144
|
-
message: 'Confluence account email (Server/Data Center: username, or empty for a personal access token)',
|
|
145
|
-
placeholder: 'you@company.com',
|
|
146
|
-
initialValue: config.get('confluence.email') || '',
|
|
147
|
-
}),
|
|
148
|
-
token: () => p.password({
|
|
149
|
-
message: 'Confluence API token (Server/Data Center: personal access token or password)',
|
|
150
|
-
}),
|
|
151
|
-
defaultSpace: () => p.text({
|
|
152
|
-
message: 'Confluence default space key',
|
|
153
|
-
placeholder: 'DEV',
|
|
154
|
-
initialValue: config.get('confluence.defaultSpace') || '',
|
|
155
|
-
}),
|
|
156
|
-
}, {
|
|
157
|
-
onCancel: () => {
|
|
158
|
-
p.cancel('Setup cancelled.');
|
|
159
|
-
process.exit(0);
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
const reuseConfluenceCreds = confluence.email && confluence.token
|
|
164
|
-
? await p.confirm({
|
|
165
|
-
message: 'Reuse Confluence email/token for Jira Service Desk Cloud?',
|
|
166
|
-
initialValue: true,
|
|
167
|
-
})
|
|
168
|
-
: false;
|
|
169
|
-
|
|
170
|
-
if (p.isCancel(reuseConfluenceCreds)) {
|
|
171
|
-
p.cancel('Setup cancelled.');
|
|
172
|
-
process.exit(0);
|
|
140
|
+
export function isDefault(connections, index) {
|
|
141
|
+
return defaultIndex(connections, connections[index].type) === index;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A reference is a connection label; a bare type falls back to that type's default connection.
|
|
145
|
+
function findIndex(connections, ref) {
|
|
146
|
+
const byLabel = connections.findIndex((c) => label(c) === ref);
|
|
147
|
+
if (byLabel !== -1 || !types[ref]) return byLabel;
|
|
148
|
+
return defaultIndex(connections, ref);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function requireIndex(connections, ref) {
|
|
152
|
+
const index = findIndex(connections, ref);
|
|
153
|
+
if (index === -1) throw new Error(`No connection named "${ref}". Run \`dev config show\` to list connections.`);
|
|
154
|
+
return index;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function getConnection(type, ref) {
|
|
158
|
+
const connections = listConnections();
|
|
159
|
+
const { title } = types[type];
|
|
160
|
+
if (ref) {
|
|
161
|
+
const connection = connections[requireIndex(connections, ref)];
|
|
162
|
+
if (connection.type !== type) {
|
|
163
|
+
throw new Error(`Connection "${ref}" is a ${types[connection.type].title} connection, not ${title}.`);
|
|
164
|
+
}
|
|
165
|
+
return connection;
|
|
173
166
|
}
|
|
167
|
+
const index = defaultIndex(connections, type);
|
|
168
|
+
if (index === -1) throw new Error(`No ${title} connection configured. Run \`dev config add ${type}\` to add one.`);
|
|
169
|
+
return connections[index];
|
|
170
|
+
}
|
|
174
171
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
placeholder: 'https://company.atlassian.net',
|
|
179
|
-
initialValue: config.get('serviceDesk.baseUrl') || '',
|
|
180
|
-
}),
|
|
181
|
-
email: () => reuseConfluenceCreds
|
|
182
|
-
? Promise.resolve(confluence.email)
|
|
183
|
-
: p.text({
|
|
184
|
-
message: 'Service Desk account email',
|
|
185
|
-
placeholder: 'you@company.com',
|
|
186
|
-
initialValue: config.get('serviceDesk.email') || '',
|
|
187
|
-
}),
|
|
188
|
-
token: () => reuseConfluenceCreds
|
|
189
|
-
? Promise.resolve(confluence.token)
|
|
190
|
-
: p.password({
|
|
191
|
-
message: 'Service Desk API token',
|
|
192
|
-
}),
|
|
193
|
-
defaultProject: () => p.text({
|
|
194
|
-
message: 'Service Desk default project key',
|
|
195
|
-
placeholder: 'TUKI',
|
|
196
|
-
initialValue: config.get('serviceDesk.defaultProject') || '',
|
|
197
|
-
}),
|
|
198
|
-
}, {
|
|
199
|
-
onCancel: () => {
|
|
200
|
-
p.cancel('Setup cancelled.');
|
|
201
|
-
process.exit(0);
|
|
202
|
-
},
|
|
203
|
-
});
|
|
172
|
+
export function resolveIndex(ref) {
|
|
173
|
+
return requireIndex(listConnections(), ref);
|
|
174
|
+
}
|
|
204
175
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
176
|
+
export function validateName(connections, connection, index = -1) {
|
|
177
|
+
const { name, type } = connection;
|
|
178
|
+
if (name) {
|
|
179
|
+
if (!/^[A-Za-z0-9][\w-]*$/.test(name)) {
|
|
180
|
+
return 'Use only letters, digits, "-" and "_", starting with a letter or digit';
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(types).some((t) => t.toLowerCase() === name.toLowerCase())) {
|
|
183
|
+
return `"${name}" is a connection type and cannot be a name`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const clash = connections.some((c, i) => i !== index && label(c) === label(connection));
|
|
187
|
+
if (clash) {
|
|
188
|
+
return name
|
|
189
|
+
? `A connection named "${name}" already exists`
|
|
190
|
+
: `A ${types[type].title} connection without a name already exists; give this one a name`;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
209
194
|
|
|
210
|
-
|
|
195
|
+
// Without an index the connection is appended.
|
|
196
|
+
export function saveConnection(connection, index = -1) {
|
|
197
|
+
const connections = listConnections();
|
|
198
|
+
const problem = validateName(connections, connection, index);
|
|
199
|
+
if (problem) throw new Error(problem);
|
|
200
|
+
if (index === -1) connections.push(connection);
|
|
201
|
+
else connections[index] = connection;
|
|
202
|
+
write(connections);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function removeConnection(ref) {
|
|
206
|
+
const connections = listConnections();
|
|
207
|
+
const [removed] = connections.splice(requireIndex(connections, ref), 1);
|
|
208
|
+
write(connections);
|
|
209
|
+
return removed;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function setDefault(ref) {
|
|
213
|
+
const connections = listConnections();
|
|
214
|
+
const target = connections[requireIndex(connections, ref)];
|
|
215
|
+
for (const c of connections) {
|
|
216
|
+
if (c.type === target.type) c.default = c === target;
|
|
217
|
+
}
|
|
218
|
+
write(connections);
|
|
219
|
+
return target;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A bare type with no connection yet creates one, so `config set jira.baseUrl ...` works on a fresh install.
|
|
223
|
+
export function setField(ref, field, value) {
|
|
224
|
+
const connections = listConnections();
|
|
225
|
+
let index = findIndex(connections, ref);
|
|
226
|
+
if (index === -1) {
|
|
227
|
+
if (!types[ref]) throw new Error(`No connection named "${ref}". Run \`dev config show\` to list connections.`);
|
|
228
|
+
connections.push({ type: ref });
|
|
229
|
+
index = connections.length - 1;
|
|
230
|
+
}
|
|
231
|
+
const connection = connections[index];
|
|
232
|
+
const fields = Object.keys(types[connection.type].fields);
|
|
233
|
+
if (field !== 'name' && !fields.includes(field)) {
|
|
234
|
+
throw new Error(`Invalid field "${field}" for a ${types[connection.type].title} connection. Valid fields: name, ${fields.join(', ')}`);
|
|
235
|
+
}
|
|
236
|
+
connection[field] = value;
|
|
237
|
+
const problem = field === 'name'
|
|
238
|
+
? validateName(connections, connection, index)
|
|
239
|
+
: fieldProblem(connection.type, field, value);
|
|
240
|
+
if (problem) throw new Error(problem);
|
|
241
|
+
write(connections);
|
|
242
|
+
return connection;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function configPath() {
|
|
246
|
+
return config.path;
|
|
211
247
|
}
|
|
212
248
|
|
|
213
249
|
export default config;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { table, heading, field } from '../utils/output.js';
|
|
4
|
+
import { outputJson } from '../utils/mode.js';
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
let convert;
|
|
8
|
+
|
|
9
|
+
export function graphText(value) {
|
|
10
|
+
return stripVTControlCharacters(String(value ?? '')).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function bodyText(body) {
|
|
14
|
+
if (!body) return '';
|
|
15
|
+
if (body.contentType?.toLowerCase() !== 'html') return graphText(body.content);
|
|
16
|
+
convert ||= require('html-to-text').convert;
|
|
17
|
+
return graphText(convert(body.content || '', { wordwrap: false, selectors: [{ selector: 'img', format: 'skip' }] }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function person(recipient) {
|
|
21
|
+
return recipient ? { name: recipient.emailAddress?.name || null, address: recipient.emailAddress?.address || null } : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function people(recipients) {
|
|
25
|
+
return (recipients || []).map(person);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function curateCalendarJson(calendar) {
|
|
29
|
+
return {
|
|
30
|
+
id: calendar.id,
|
|
31
|
+
name: calendar.name || '',
|
|
32
|
+
owner: calendar.owner || null,
|
|
33
|
+
isDefault: calendar.isDefaultCalendar ?? false,
|
|
34
|
+
canViewPrivateItems: calendar.canViewPrivateItems ?? false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function curateEventJson(event) {
|
|
39
|
+
return {
|
|
40
|
+
id: event.id,
|
|
41
|
+
subject: event.subject || '',
|
|
42
|
+
start: event.start || null,
|
|
43
|
+
end: event.end || null,
|
|
44
|
+
isAllDay: event.isAllDay ?? false,
|
|
45
|
+
isCancelled: event.isCancelled ?? false,
|
|
46
|
+
response: event.responseStatus?.response || null,
|
|
47
|
+
organizer: person(event.organizer),
|
|
48
|
+
attendees: (event.attendees || []).map((a) => ({ ...person(a), type: a.type || null, response: a.status?.response || null })),
|
|
49
|
+
location: event.location?.displayName || null,
|
|
50
|
+
type: event.type || null,
|
|
51
|
+
seriesId: event.seriesMasterId || null,
|
|
52
|
+
body: bodyText(event.body),
|
|
53
|
+
joinUrl: event.onlineMeeting?.joinUrl || null,
|
|
54
|
+
url: event.webLink || null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function curateNextEventJson(result) {
|
|
59
|
+
return {
|
|
60
|
+
from: result.from,
|
|
61
|
+
to: result.to,
|
|
62
|
+
event: result.event ? curateEventJson(result.event) : null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function curateAvailabilityJson(result) {
|
|
67
|
+
return {
|
|
68
|
+
from: result.from, to: result.to, intervalMinutes: result.intervalMinutes,
|
|
69
|
+
legend: { 0: 'free or working elsewhere', 1: 'tentative', 2: 'busy', 3: 'out of office' },
|
|
70
|
+
schedules: result.schedules.map((schedule) => ({
|
|
71
|
+
address: schedule.scheduleId,
|
|
72
|
+
error: schedule.error ? { code: schedule.error.responseCode || null, message: schedule.error.message || 'Availability is unknown.' } : null,
|
|
73
|
+
availabilityView: schedule.error ? null : schedule.availabilityView ?? null,
|
|
74
|
+
items: (schedule.scheduleItems || []).map((item) => ({
|
|
75
|
+
status: item.status || 'unknown', start: item.start || null, end: item.end || null,
|
|
76
|
+
subject: item.subject || null, location: item.location || null, isPrivate: item.isPrivate ?? null,
|
|
77
|
+
})),
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatNextEvent(data, options) {
|
|
83
|
+
if (options.mode === 'json') { outputJson(data); return; }
|
|
84
|
+
formatGraph(data.event ? { from: data.from, to: data.to, ...data.event } : {
|
|
85
|
+
from: data.from, to: data.to, message: 'No upcoming event found in this calendar and date range.',
|
|
86
|
+
}, options);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatAvailability(data, options) {
|
|
90
|
+
if (options.mode === 'json') { outputJson(data); return; }
|
|
91
|
+
console.log(`Window: ${data.from} to ${data.to} (UTC; ${data.intervalMinutes}-minute slots)`);
|
|
92
|
+
for (const schedule of data.schedules) {
|
|
93
|
+
formatGraph(schedule, { ...options, title: `Availability: ${schedule.address}` });
|
|
94
|
+
}
|
|
95
|
+
console.log('Slot codes: 0=free/working elsewhere, 1=tentative, 2=busy, 3=out of office. Errors mean unknown, not free.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function curateChatJson(chat) {
|
|
99
|
+
return {
|
|
100
|
+
id: chat.id,
|
|
101
|
+
topic: chat.topic || null,
|
|
102
|
+
type: chat.chatType || null,
|
|
103
|
+
members: (chat.members || []).map((m) => ({ id: m.userId || m.id, name: m.displayName || null, email: m.email || null })),
|
|
104
|
+
createdAt: chat.createdDateTime || null,
|
|
105
|
+
updatedAt: chat.lastUpdatedDateTime || null,
|
|
106
|
+
url: chat.webUrl || null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function curateChatMessageJson(message) {
|
|
111
|
+
return {
|
|
112
|
+
id: message.id,
|
|
113
|
+
chatId: message.chatId || null,
|
|
114
|
+
type: message.messageType || null,
|
|
115
|
+
from: message.from?.user?.displayName || message.from?.application?.displayName || null,
|
|
116
|
+
createdAt: message.createdDateTime || null,
|
|
117
|
+
editedAt: message.lastEditedDateTime || null,
|
|
118
|
+
deletedAt: message.deletedDateTime || null,
|
|
119
|
+
body: bodyText(message.body),
|
|
120
|
+
attachments: (message.attachments || []).map((a) => ({ id: a.id, name: a.name || null, type: a.contentType || null })),
|
|
121
|
+
url: message.webUrl || null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function curateMailJson(message) {
|
|
126
|
+
return {
|
|
127
|
+
id: message.id,
|
|
128
|
+
subject: message.subject || '',
|
|
129
|
+
from: person(message.from),
|
|
130
|
+
to: people(message.toRecipients),
|
|
131
|
+
...(message.ccRecipients ? { cc: people(message.ccRecipients) } : {}),
|
|
132
|
+
receivedAt: message.receivedDateTime || null,
|
|
133
|
+
sentAt: message.sentDateTime || null,
|
|
134
|
+
isRead: message.isRead ?? null,
|
|
135
|
+
hasAttachments: message.hasAttachments ?? false,
|
|
136
|
+
folderId: message.parentFolderId || null,
|
|
137
|
+
preview: message.bodyPreview || '',
|
|
138
|
+
...(message.body ? { body: bodyText(message.body) } : {}),
|
|
139
|
+
url: message.webLink || null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function curateFolderJson(folder) {
|
|
144
|
+
return { id: folder.id, name: folder.displayName || '', total: folder.totalItemCount ?? 0, unread: folder.unreadItemCount ?? 0, childFolders: folder.childFolderCount ?? 0 };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function curateSiteJson(site) {
|
|
148
|
+
return { id: site.id, name: site.displayName || site.name || '', description: site.description || '', url: site.webUrl || null };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function curateDriveJson(drive) {
|
|
152
|
+
return { id: drive.id, name: drive.name || '', type: drive.driveType || null, description: drive.description || '', url: drive.webUrl || null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function curateFileJson(file) {
|
|
156
|
+
return {
|
|
157
|
+
id: file.id,
|
|
158
|
+
name: file.name || '',
|
|
159
|
+
type: file.folder ? 'folder' : file.file ? 'file' : 'item',
|
|
160
|
+
size: file.size ?? null,
|
|
161
|
+
mimeType: file.file?.mimeType || null,
|
|
162
|
+
childCount: file.folder?.childCount ?? null,
|
|
163
|
+
driveId: file.parentReference?.driveId || null,
|
|
164
|
+
parentId: file.parentReference?.id || null,
|
|
165
|
+
updatedAt: file.lastModifiedDateTime || null,
|
|
166
|
+
url: file.webUrl || null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function curateListJson(list) {
|
|
171
|
+
return { id: list.id, name: list.displayName || list.name || '', description: list.description || '', template: list.list?.template || null, url: list.webUrl || null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function curateListItemJson(item) {
|
|
175
|
+
return {
|
|
176
|
+
id: item.id,
|
|
177
|
+
fields: Object.fromEntries(Object.entries(item.fields || {}).filter(([key]) => !key.startsWith('@odata.'))),
|
|
178
|
+
createdAt: item.createdDateTime || null,
|
|
179
|
+
updatedAt: item.lastModifiedDateTime || null,
|
|
180
|
+
url: item.webUrl || null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function display(value) {
|
|
185
|
+
if (value === null || value === undefined) return '';
|
|
186
|
+
return graphText(typeof value === 'object' ? JSON.stringify(value) : value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function formatGraph(data, { mode, title, columns = [], records = false }) {
|
|
190
|
+
if (mode === 'json') { outputJson(data); return; }
|
|
191
|
+
const items = Array.isArray(data) ? data : [data];
|
|
192
|
+
if (!items.length) { console.log('No results found.'); return; }
|
|
193
|
+
if (mode === 'rich') heading(graphText(title));
|
|
194
|
+
if (Array.isArray(data) && !records) {
|
|
195
|
+
const rows = items.map((item) => columns.map(([, value]) => display(value(item))));
|
|
196
|
+
if (mode === 'plain') {
|
|
197
|
+
for (const row of rows) console.log(row.map((cell) => cell.replace(/\s+/g, ' ')).join('\t'));
|
|
198
|
+
} else {
|
|
199
|
+
const widths = columns.map(([, , width]) => width || 32);
|
|
200
|
+
const fits = widths.reduce((sum, width) => sum + width, columns.length + 1) <= (process.stdout.columns || 120)
|
|
201
|
+
&& rows.every((row) => row.every((cell, index) => cell.split('\n').every((line) => line.length <= widths[index] - 2)));
|
|
202
|
+
if (fits) console.log(table(columns.map(([label]) => label), rows, { colWidths: widths }));
|
|
203
|
+
else {
|
|
204
|
+
// Resource IDs must remain complete and copyable when a table would truncate them.
|
|
205
|
+
for (const row of rows) {
|
|
206
|
+
for (const [index, cell] of row.entries()) field(columns[index][0], cell);
|
|
207
|
+
console.log('');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (const [index, item] of items.entries()) {
|
|
214
|
+
if (index) console.log('');
|
|
215
|
+
for (const [key, value] of Object.entries(item)) {
|
|
216
|
+
if (mode === 'rich') field(key, display(value));
|
|
217
|
+
else console.log(`${key}: ${display(value)}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|