hymnkeep 0.0.0-stage → 0.1.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/dist/tasks.js ADDED
@@ -0,0 +1,288 @@
1
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
3
+ export function today() {
4
+ const now = new Date();
5
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
6
+ }
7
+ function addDays(date, days) {
8
+ const at = new Date(`${date}T12:00:00Z`);
9
+ at.setUTCDate(at.getUTCDate() + days);
10
+ return at.toISOString().slice(0, 10);
11
+ }
12
+ const same = (a, b) => (a ?? '').toLowerCase() === b.toLowerCase();
13
+ const has = (a, b) => (a ?? '').toLowerCase().includes(b.toLowerCase());
14
+ /** One of several named things, by id or name: an exact name first, then the only one containing it. */
15
+ function pick(items, wanted, name, what) {
16
+ const byId = items.find((item) => item.id === wanted);
17
+ const exact = items.filter((item) => name(item).some((n) => same(n, wanted)));
18
+ const partial = items.filter((item) => name(item).some((n) => has(n, wanted)));
19
+ const found = byId ? [byId] : exact.length > 0 ? exact : partial;
20
+ if (found.length === 1) {
21
+ return found[0];
22
+ }
23
+ const names = (found.length > 1 ? found : items).map((item) => ` ${name(item)[0]}`).join('\n');
24
+ throw new Error(found.length > 1 ? `More than one ${what} matches "${wanted}":\n${names}` : `No ${what} matches "${wanted}". There are:\n${names}`);
25
+ }
26
+ export async function whoami(api) {
27
+ const me = await api.get('whoami');
28
+ return {
29
+ data: me,
30
+ text: `${me.person.name}, ${me.role} at ${me.community.name} (${me.community.slug}). This key ${me.key.writes ? 'reads and writes' : 'only reads'}.`,
31
+ };
32
+ }
33
+ export async function occasions(api, from = today(), to = addDays(from, 41)) {
34
+ const { data } = await api.get('occasions', { from, to });
35
+ return {
36
+ data,
37
+ text: data.length === 0
38
+ ? `Nothing planned from ${from} to ${to}.`
39
+ : data.map((o) => `${o.observance_date} ${o.name}${o.services.length ? ` (${o.services.map((s) => s.day_time).join(', ')})` : ''}`).join('\n'),
40
+ };
41
+ }
42
+ /** The occasion an id names, or the first on or after a date (today when neither is given). */
43
+ export async function day(api, when) {
44
+ if (when && UUID.test(when)) {
45
+ return (await api.get(`occasions/${when}`)).data;
46
+ }
47
+ const from = when && DATE.test(when) ? when : today();
48
+ const { data } = await api.get('occasions', { from, to: addDays(from, 13) });
49
+ if (data.length === 0) {
50
+ throw new Error(`Nothing is planned in the two weeks from ${from}.`);
51
+ }
52
+ return (await api.get(`occasions/${data[0].id}`)).data;
53
+ }
54
+ export function services(of, wanted = []) {
55
+ if (wanted.length === 0) {
56
+ return of.services;
57
+ }
58
+ return wanted.map((each) => pick(of.services, each, (s) => [s.display, s.day_time, s.time, s.label ?? '', s.venue ?? ''], 'service'));
59
+ }
60
+ const title = (row) => row.title ?? 'nothing chosen yet';
61
+ /** Every place of the day once, a place one service adds falling where it falls in that service's order. */
62
+ export function placeOrder(of) {
63
+ const order = [];
64
+ for (const service of of.services) {
65
+ let after = -1;
66
+ for (const row of of.music.filter((r) => r.service_id === service.id).sort((a, b) => a.position - b.position)) {
67
+ const at = order.indexOf(row.slot);
68
+ if (at === -1) {
69
+ order.splice(after + 1, 0, row.slot);
70
+ after += 1;
71
+ }
72
+ else {
73
+ after = at;
74
+ }
75
+ }
76
+ }
77
+ return order;
78
+ }
79
+ /** The day as a person reads it: its music place by place, then who is on each service. */
80
+ export function describeDay(of) {
81
+ const lines = [`${of.name}, ${of.observance_date}`, `Services: ${of.services.map((s) => s.display).join('; ')}`, '', 'Music'];
82
+ const slots = placeOrder(of);
83
+ for (const slot of slots) {
84
+ const rows = of.music.filter((row) => row.slot === slot);
85
+ const fills = new Set(rows.map(title));
86
+ const everywhere = rows.length === of.services.length && fills.size === 1;
87
+ if (everywhere) {
88
+ lines.push(` ${slot}: ${title(rows[0])}`);
89
+ }
90
+ else {
91
+ lines.push(` ${slot}:`);
92
+ for (const row of rows) {
93
+ lines.push(` ${of.services.find((s) => s.id === row.service_id)?.day_time}: ${title(row)}`);
94
+ }
95
+ }
96
+ }
97
+ lines.push('', 'Musicians');
98
+ for (const service of of.services) {
99
+ const booked = (of.people[service.id] ?? []).filter((a) => a.status !== 'cancelled');
100
+ lines.push(` ${service.display}: ${booked.length ? booked.map((a) => (a.job ? `${a.party} (${a.job})` : a.party)).join(', ') : 'nobody yet'}`);
101
+ }
102
+ return lines.join('\n');
103
+ }
104
+ export async function show(api, when) {
105
+ const found = await day(api, when);
106
+ return { data: found, text: describeDay(found) };
107
+ }
108
+ /** Music in the library and the adopted hymnals, with its page numbers for a date. */
109
+ export async function find(api, query, date) {
110
+ const { data } = await api.get('music', { q: query, date });
111
+ return {
112
+ data: data.pieces,
113
+ text: data.pieces.length === 0
114
+ ? `Nothing found for "${query}".`
115
+ : data.pieces.map((p) => {
116
+ const pages = (p.pages ?? []).map((pg) => `${pg.book} ${pg.number ?? ''}`.trim()).join(', ');
117
+ const used = p.recent_use ? `; ${p.recent_use.is_past ? 'last sung' : 'planned'} ${p.recent_use.date}` : '';
118
+ return `${p.title}${p.credit || p.composer ? ` (${p.credit ?? p.composer})` : ''}${pages ? ` · ${pages}` : ''}${used}`;
119
+ }).join('\n'),
120
+ };
121
+ }
122
+ /** A piece by id, or by its title when one piece has it. */
123
+ async function piece(api, wanted, date) {
124
+ if (UUID.test(wanted)) {
125
+ return { body: { piece_id: wanted }, title: wanted };
126
+ }
127
+ const { data } = await api.get('music', { q: wanted, date });
128
+ const chosen = pick(data.pieces, wanted, (p) => [p.title], 'piece');
129
+ return {
130
+ body: chosen.source?.kind === 'library' || !chosen.work_id ? { piece_id: chosen.id } : { work_id: chosen.work_id },
131
+ title: chosen.title,
132
+ };
133
+ }
134
+ /** Each named service's answer when a piece or a person spreads to it: none asked where it is free. */
135
+ function answers(states, targets, taken, absent) {
136
+ const ids = targets.map((t) => t.id);
137
+ const out = {};
138
+ for (const state of states) {
139
+ if (!ids.includes(state.service_id)) {
140
+ continue;
141
+ }
142
+ if (state.state === 'free') {
143
+ out[state.service_id] = null;
144
+ }
145
+ else if (state.state === 'taken') {
146
+ out[state.service_id] = taken;
147
+ }
148
+ else if (state.state === 'absent' && absent) {
149
+ out[state.service_id] = absent;
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+ /** A piece in a place, at every service named: those whose place holds another take this one. */
155
+ export async function put(api, options) {
156
+ const found = await day(api, options.when);
157
+ const targets = services(found, options.services);
158
+ const slotName = pick(uniqueSlots(found), options.slot, (s) => [s.name], 'slot').name;
159
+ const first = found.music.find((row) => row.slot === slotName && targets.some((t) => t.id === row.service_id));
160
+ if (!first) {
161
+ throw new Error(`None of those services has ${slotName}. Add it first with add-slot.`);
162
+ }
163
+ const chosen = await piece(api, options.piece, found.observance_date);
164
+ const path = `occasions/${found.id}/services/${first.service_id}/slots/${first.id}`;
165
+ const { data: states } = await api.get(`${path}/spread`, chosen.body.piece_id ? { piece_id: chosen.body.piece_id } : {});
166
+ const spread = answers(states, targets, options.both ? 'both' : 'replace', 'add');
167
+ const at = [first.service_id, ...Object.keys(spread)].map((id) => found.services.find((s) => s.id === id)?.display).filter(Boolean);
168
+ if (options.preview) {
169
+ return { data: { piece: chosen, slot: slotName, services: at, states }, text: `Would put ${chosen.title} in ${slotName} at ${at.join(', ')}.` };
170
+ }
171
+ const reply = await api.patch(path, { slot_id: first.slot_id, ...chosen.body, spread });
172
+ return { data: reply, text: `Put ${chosen.title} in ${slotName} at ${at.join(', ')}.` };
173
+ }
174
+ function uniqueSlots(of) {
175
+ const seen = new Map();
176
+ for (const row of of.music) {
177
+ seen.set(row.slot, { id: row.slot_id, name: row.slot });
178
+ }
179
+ return [...seen.values()];
180
+ }
181
+ /** A slot with nothing in it yet, added to each service named. */
182
+ export async function addSlot(api, options) {
183
+ const found = await day(api, options.when);
184
+ const targets = services(found, options.services);
185
+ const { data: slots } = await api.get('slots');
186
+ const slot = pick(slots.filter((s) => !s.hidden), options.slot, (s) => [s.name], 'slot name');
187
+ const reply = await api.post(`occasions/${found.id}/day/slots`, { slot_id: slot.id, service_ids: targets.map((t) => t.id) });
188
+ return { data: reply, text: `Added ${slot.name} at ${targets.map((t) => t.display).join(', ')}.` };
189
+ }
190
+ /** A slot taken off each service named, with whatever is in it. */
191
+ export async function removeSlot(api, options) {
192
+ const found = await day(api, options.when);
193
+ const targets = services(found, options.services);
194
+ const slotName = pick(uniqueSlots(found), options.slot, (s) => [s.name], 'slot').name;
195
+ const rows = found.music.filter((row) => row.slot === slotName && targets.some((t) => t.id === row.service_id));
196
+ const reply = await api.post(`occasions/${found.id}/day/slots/remove`, { ids: rows.map((row) => row.id) });
197
+ return { data: reply, text: `Removed ${slotName} from ${rows.map((row) => found.services.find((s) => s.id === row.service_id)?.display).join(', ')}.` };
198
+ }
199
+ /** A person in a job, or an ensemble, at every service named: those where someone else does the job have them replaced. */
200
+ export async function assign(api, options) {
201
+ const found = await day(api, options.when);
202
+ const targets = services(found, options.services);
203
+ const { data: parties } = await api.get('parties');
204
+ const everyone = [...parties.memberships.map((m) => ({ ...m, kind: 'membership' })), ...parties.ensembles.map((e) => ({ ...e, kind: 'ensemble' }))];
205
+ const who = pick(everyone, options.who, (p) => [p.name], 'person or ensemble');
206
+ let jobId = null;
207
+ let jobName = '';
208
+ if (who.kind === 'membership') {
209
+ if (!options.job) {
210
+ throw new Error(`Which job is ${who.name} doing? Add --job.`);
211
+ }
212
+ const { data: jobs } = await api.get('jobs');
213
+ const job = pick(jobs.filter((j) => !j.archived), options.job, (j) => [j.name], 'job');
214
+ jobId = job.id;
215
+ jobName = ` as ${job.name}`;
216
+ }
217
+ const party = who.kind === 'membership' ? { party_type: 'membership', membership_id: who.id } : { party_type: 'ensemble', ensemble_id: who.id };
218
+ const first = targets[0];
219
+ const path = `occasions/${found.id}/services/${first.id}/assignments`;
220
+ const { data: states } = await api.get(`${path}/spread`, { ...party, job_id: jobId ?? undefined, party_type: undefined });
221
+ const spread = answers(states, targets.slice(1), options.both ? 'both' : 'replace', null);
222
+ const at = [first, ...targets.slice(1).filter((t) => t.id in spread)].map((t) => t.display);
223
+ if (options.preview) {
224
+ return { data: { who: who.name, job: jobName, services: at, states }, text: `Would assign ${who.name}${jobName} at ${at.join(', ')}.` };
225
+ }
226
+ const reply = await api.post(path, { ...party, job_id: jobId, spread });
227
+ return { data: reply, text: `Assigned ${who.name}${jobName} at ${at.join(', ')}.` };
228
+ }
229
+ /** A person or an ensemble taken off each service named. */
230
+ export async function unassign(api, options) {
231
+ const found = await day(api, options.when);
232
+ const targets = services(found, options.services);
233
+ const gone = [];
234
+ for (const service of targets) {
235
+ const rows = (found.people[service.id] ?? []).filter((a) => a.status !== 'cancelled' && same(a.party, options.who) && (!options.job || same(a.job, options.job)));
236
+ for (const row of rows) {
237
+ await api.delete(`occasions/${found.id}/services/${service.id}/assignments/${row.id}`);
238
+ gone.push(service.display);
239
+ }
240
+ }
241
+ if (gone.length === 0) {
242
+ throw new Error(`${options.who} is not on ${targets.map((t) => t.display).join(', ')}.`);
243
+ }
244
+ return { data: { removed: gone }, text: `Took ${options.who} off ${gone.join(', ')}.` };
245
+ }
246
+ /** One service's music onto another's, replacing it or after it. */
247
+ export async function copy(api, options) {
248
+ const found = await day(api, options.when);
249
+ const [from] = services(found, [options.from]);
250
+ const [to] = services(found, [options.to]);
251
+ 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}${options.replace ? ', replacing what it had' : ''}.` };
253
+ }
254
+ /**
255
+ * A date planned from its templates: what they would write, and any question
256
+ * they raise. Nothing is written while a question has no answer.
257
+ */
258
+ export async function plan(api, options) {
259
+ const query = { date: options.date, templates: options.templates };
260
+ for (const [key, value] of Object.entries(options.answers ?? {})) {
261
+ query[`answers[${key}]`] = value;
262
+ }
263
+ const { data: preview } = await api.get('occasions/preview', query);
264
+ const open = preview.collisions.filter((c) => c.answer === null || c.answer === undefined);
265
+ const described = [
266
+ `${preview.day.name}, ${options.date}`,
267
+ ...preview.contributors.map((c) => ` ${c.sentence}`),
268
+ ...preview.services.map((s) => ` ${s.time_label}${s.label ? ` · ${s.label}` : ''}: ${s.slots.length} slots`),
269
+ ];
270
+ if (open.length > 0) {
271
+ return {
272
+ data: { preview, questions: open },
273
+ text: [...described, '', 'Questions to answer first (--answer key=value):', ...open.map((q) => ` ${q.key}: ${q.ask} ${q.options.map((o) => `[${o.value}] ${o.label}`).join(' ')}`)].join('\n'),
274
+ };
275
+ }
276
+ if (options.preview) {
277
+ return { data: { preview }, text: [...described, '', 'Nothing written yet.'].join('\n') };
278
+ }
279
+ const reply = await api.post('occasions', {
280
+ observance_date: options.date,
281
+ name: preview.day.name,
282
+ kind: preview.kind,
283
+ is_public: true,
284
+ templates: options.templates ?? preview.templates,
285
+ answers: options.answers ?? {},
286
+ });
287
+ return { data: reply, text: `Planned ${preview.day.name} on ${options.date}.\n\n${describeDay(reply.data)}` };
288
+ }