hymnkeep 0.1.1 → 0.1.3
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 +37 -6
- package/dist/cli.js +44 -13
- package/dist/client.js +16 -5
- package/dist/mcp.js +56 -13
- package/dist/operations.js +71 -7
- package/dist/params.js +173 -0
- package/dist/tasks.js +113 -5
- package/openapi.json +768 -206
- package/package.json +1 -1
package/dist/tasks.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Upload } from './params.js';
|
|
1
2
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2
3
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
3
4
|
export function today() {
|
|
@@ -21,7 +22,10 @@ function pick(items, wanted, name, what) {
|
|
|
21
22
|
return found[0];
|
|
22
23
|
}
|
|
23
24
|
const names = (found.length > 1 ? found : items).map((item) => ` ${name(item)[0]}`).join('\n');
|
|
24
|
-
|
|
25
|
+
if (found.length > 1) {
|
|
26
|
+
throw new Error(`More than one ${what} matches "${wanted}":\n${names}`);
|
|
27
|
+
}
|
|
28
|
+
throw new Error(items.length === 0 ? `No ${what} matches "${wanted}".` : `No ${what} matches "${wanted}". There are:\n${names}`);
|
|
25
29
|
}
|
|
26
30
|
export async function whoami(api) {
|
|
27
31
|
const me = await api.get('whoami');
|
|
@@ -184,6 +188,9 @@ export async function addSlot(api, options) {
|
|
|
184
188
|
const targets = services(found, options.services);
|
|
185
189
|
const { data: slots } = await api.get('slots');
|
|
186
190
|
const slot = pick(slots.filter((s) => !s.hidden), options.slot, (s) => [s.name], 'slot name');
|
|
191
|
+
if (options.preview) {
|
|
192
|
+
return { data: { slot, services: targets }, text: `Would add ${slot.name}, empty, at ${targets.map((t) => t.display).join(', ')}. Nothing was sent.` };
|
|
193
|
+
}
|
|
187
194
|
const reply = await api.post(`occasions/${found.id}/day/slots`, { slot_id: slot.id, service_ids: targets.map((t) => t.id) });
|
|
188
195
|
return { data: reply, text: `Added ${slot.name} at ${targets.map((t) => t.display).join(', ')}.` };
|
|
189
196
|
}
|
|
@@ -192,9 +199,30 @@ export async function removeSlot(api, options) {
|
|
|
192
199
|
const found = await day(api, options.when);
|
|
193
200
|
const targets = services(found, options.services);
|
|
194
201
|
const slotName = pick(uniqueSlots(found), options.slot, (s) => [s.name], 'slot').name;
|
|
195
|
-
const rows =
|
|
202
|
+
const rows = [];
|
|
203
|
+
// One place a service, never more: where a service has the slot twice,
|
|
204
|
+
// the empty one goes, or the one holding the piece named.
|
|
205
|
+
for (const service of targets) {
|
|
206
|
+
const here = found.music.filter((row) => row.slot === slotName && row.service_id === service.id);
|
|
207
|
+
const held = options.holding ? here.filter((row) => has(row.title, options.holding)) : here;
|
|
208
|
+
if (here.length === 0 || (options.holding && held.length === 0)) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const empty = held.filter((row) => !row.piece_id);
|
|
212
|
+
if (held.length > 1 && empty.length === 0 && new Set(held.map((row) => row.piece_id)).size > 1) {
|
|
213
|
+
throw new Error(`${service.display} has ${slotName} ${held.length} times: ${held.map((row) => title(row)).join(', ')}. Say which with --holding <piece>.`);
|
|
214
|
+
}
|
|
215
|
+
rows.push(empty[0] ?? held[0]);
|
|
216
|
+
}
|
|
217
|
+
if (rows.length === 0) {
|
|
218
|
+
throw new Error(`None of those services has ${slotName}${options.holding ? ` holding ${options.holding}` : ''}.`);
|
|
219
|
+
}
|
|
220
|
+
const where = rows.map((row) => `${found.services.find((s) => s.id === row.service_id)?.display}${row.piece_id ? ` (holding ${title(row)})` : ''}`).join(', ');
|
|
221
|
+
if (options.preview) {
|
|
222
|
+
return { data: { remove: rows }, text: `Would take ${slotName} off ${where}. Nothing was sent.` };
|
|
223
|
+
}
|
|
196
224
|
const reply = await api.post(`occasions/${found.id}/day/slots/remove`, { ids: rows.map((row) => row.id) });
|
|
197
|
-
return { data: reply, text: `
|
|
225
|
+
return { data: reply, text: `Took ${slotName} off ${where}.` };
|
|
198
226
|
}
|
|
199
227
|
/** A person in a job, or an ensemble, at every service named: those where someone else does the job have them replaced. */
|
|
200
228
|
export async function assign(api, options) {
|
|
@@ -234,13 +262,18 @@ export async function unassign(api, options) {
|
|
|
234
262
|
for (const service of targets) {
|
|
235
263
|
const rows = (found.people[service.id] ?? []).filter((a) => a.status !== 'cancelled' && same(a.party, options.who) && (!options.job || same(a.job, options.job)));
|
|
236
264
|
for (const row of rows) {
|
|
237
|
-
|
|
265
|
+
if (!options.preview) {
|
|
266
|
+
await api.delete(`occasions/${found.id}/services/${service.id}/assignments/${row.id}`);
|
|
267
|
+
}
|
|
238
268
|
gone.push(service.display);
|
|
239
269
|
}
|
|
240
270
|
}
|
|
241
271
|
if (gone.length === 0) {
|
|
242
272
|
throw new Error(`${options.who} is not on ${targets.map((t) => t.display).join(', ')}.`);
|
|
243
273
|
}
|
|
274
|
+
if (options.preview) {
|
|
275
|
+
return { data: { remove: gone }, text: `Would take ${options.who} off ${gone.join(', ')}. Nothing was sent.` };
|
|
276
|
+
}
|
|
244
277
|
return { data: { removed: gone }, text: `Took ${options.who} off ${gone.join(', ')}.` };
|
|
245
278
|
}
|
|
246
279
|
/** One service's music onto another's, replacing it or after it. */
|
|
@@ -248,8 +281,21 @@ export async function copy(api, options) {
|
|
|
248
281
|
const found = await day(api, options.when);
|
|
249
282
|
const [from] = services(found, [options.from]);
|
|
250
283
|
const [to] = services(found, [options.to]);
|
|
284
|
+
if (options.replace && options.add) {
|
|
285
|
+
throw new Error('Say --replace or --add, not both.');
|
|
286
|
+
}
|
|
287
|
+
// Adding after music that is already there doubles every place both
|
|
288
|
+
// services have, so a service with music in it waits to be told which.
|
|
289
|
+
const there = found.music.filter((row) => row.service_id === to.id);
|
|
290
|
+
if (there.length > 0 && !options.replace && !options.add) {
|
|
291
|
+
throw new Error(`${to.display} already has music in ${there.length} places. Say --replace to take ${from.display}'s instead, or --add to put it after.`);
|
|
292
|
+
}
|
|
293
|
+
const how = options.replace ? ', replacing what it had' : there.length > 0 ? ', after what it had' : '';
|
|
294
|
+
if (options.preview) {
|
|
295
|
+
return { data: { from, to, replace: options.replace ?? false }, text: `Would copy ${from.display}'s music to ${to.display}${how}. Nothing was sent.` };
|
|
296
|
+
}
|
|
251
297
|
const reply = await api.post(`occasions/${found.id}/services/${to.id}/copy`, { from_service_id: from.id, replace: options.replace ?? false });
|
|
252
|
-
return { data: reply, text: `Copied ${from.display}'s music to ${to.display}${
|
|
298
|
+
return { data: reply, text: `Copied ${from.display}'s music to ${to.display}${how}.` };
|
|
253
299
|
}
|
|
254
300
|
/**
|
|
255
301
|
* A date planned from its templates: what they would write, and any question
|
|
@@ -286,3 +332,65 @@ export async function plan(api, options) {
|
|
|
286
332
|
});
|
|
287
333
|
return { data: reply, text: `Planned ${preview.day.name} on ${options.date}.\n\n${describeDay(reply.data)}` };
|
|
288
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Files (PDF or MP3, up to 64 MB) onto a piece in the library: on the piece
|
|
337
|
+
* itself, or on a step it reads from (its tune, its text, one book's
|
|
338
|
+
* printing), with the parts each file holds.
|
|
339
|
+
*/
|
|
340
|
+
export async function upload(api, options) {
|
|
341
|
+
const files = options.files.map((path) => new Upload(path));
|
|
342
|
+
const bad = files.filter((file) => !['application/pdf', 'audio/mpeg'].includes(file.type));
|
|
343
|
+
if (bad.length) {
|
|
344
|
+
throw new Error(`Hymnkeep takes PDF and MP3 files only: ${bad.map((f) => f.name).join(', ')}.`);
|
|
345
|
+
}
|
|
346
|
+
const big = files.filter((file) => file.size > 64 * 1024 * 1024);
|
|
347
|
+
if (big.length) {
|
|
348
|
+
throw new Error(`Hymnkeep takes files up to 64 MB: ${big.map((f) => f.name).join(', ')} ${big.length === 1 ? 'is' : 'are'} larger.`);
|
|
349
|
+
}
|
|
350
|
+
const pieceId = UUID.test(options.piece) ? options.piece : await libraryPiece(api, options.piece);
|
|
351
|
+
const { data: shown } = await api.get(`music/${pieceId}`);
|
|
352
|
+
const places = (shown.belongs_to_options ?? []);
|
|
353
|
+
const place = options.place ? pick(places, options.place, (p) => [p.label, p.value, p.kind], 'place on this piece') : places.find((p) => p.value === 'piece');
|
|
354
|
+
const parts = options.parts?.length ? await partIds(api, options.parts) : [];
|
|
355
|
+
const where = `${shown.display_title ?? shown.title}${place && place.value !== 'piece' ? `, on ${place.label.charAt(0).toLowerCase()}${place.label.slice(1)}` : ''}`;
|
|
356
|
+
const partText = parts.length ? ` as ${parts.map((p) => p.name).join(', ')}` : '';
|
|
357
|
+
if (options.preview) {
|
|
358
|
+
return {
|
|
359
|
+
data: { piece: pieceId, belongs_to: place?.value ?? 'piece', parts: parts.map((p) => p.id), files: files.map((f) => ({ path: f.path, bytes: f.size, type: f.type })) },
|
|
360
|
+
text: `Would upload ${files.map((f) => f.name).join(', ')} to ${where}${partText}. Nothing was sent.`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const added = [];
|
|
364
|
+
for (const file of files) {
|
|
365
|
+
const form = new FormData();
|
|
366
|
+
form.append('file', file.blob(), file.name);
|
|
367
|
+
if (place) {
|
|
368
|
+
form.append('belongs_to', String(place.value));
|
|
369
|
+
}
|
|
370
|
+
const reply = await api.request('POST', `music/${pieceId}/files`, { form });
|
|
371
|
+
if (parts.length) {
|
|
372
|
+
await api.patch(`music/${pieceId}/files/${reply.file_id}`, { part_ids: parts.map((p) => p.id) });
|
|
373
|
+
}
|
|
374
|
+
added.push({ id: reply.file_id, name: file.name });
|
|
375
|
+
}
|
|
376
|
+
return { data: { piece: pieceId, files: added }, text: `Uploaded ${added.map((f) => f.name).join(', ')} to ${where}${partText}.` };
|
|
377
|
+
}
|
|
378
|
+
/** A piece in the library by its title. A hymn the community has only in a book has no files of its own to take. */
|
|
379
|
+
async function libraryPiece(api, wanted) {
|
|
380
|
+
const { data } = await api.get('music/library', { q: wanted });
|
|
381
|
+
try {
|
|
382
|
+
return pick(data, wanted, (p) => [p.title, p.display_title].filter(Boolean), 'piece in the library').id;
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
const { data: found } = await api.get('music', { q: wanted });
|
|
386
|
+
if (found.pieces.some((p) => !p.id && p.work_id)) {
|
|
387
|
+
throw new Error(`"${wanted}" is in a hymnal but not in your library, and a file needs a piece in the library. Add it to the library in Hymnkeep first.`);
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function partIds(api, names) {
|
|
393
|
+
const { data } = await api.get('parts');
|
|
394
|
+
const live = data.filter((p) => !p.archived);
|
|
395
|
+
return names.map((name) => pick(live, name, (p) => [p.name], 'part'));
|
|
396
|
+
}
|