hymnkeep 0.1.0 → 0.1.2
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 +43 -13
- package/dist/client.js +16 -5
- package/dist/mcp.js +57 -14
- package/dist/operations.js +71 -7
- package/dist/params.js +166 -0
- package/dist/tasks.js +113 -5
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -30,19 +30,50 @@ hymnkeep put "Gathering Hymn" "O Come, All Ye Faithful" --day 2026-12-27
|
|
|
30
30
|
hymnkeep put Psalm "Psalm 98" --at "Sun 8:00 AM" --preview
|
|
31
31
|
hymnkeep add-slot "Communion Meditation" --at "Sun 8:00 AM"
|
|
32
32
|
hymnkeep remove-slot Postlude --at Vigil
|
|
33
|
+
hymnkeep remove-slot Communion --at "Sun 8:00 AM" --holding "Ave Maria"
|
|
33
34
|
|
|
34
35
|
hymnkeep assign "Karis Wolfe" --job Cantor --day 2026-12-27
|
|
35
36
|
hymnkeep assign "Adult Choir" --at "Sun 11:00 AM"
|
|
36
37
|
hymnkeep unassign "Karis Wolfe" --at Vigil
|
|
37
|
-
hymnkeep copy --from "Sat 5:30 PM" --to "Sun 8:00 AM"
|
|
38
|
+
hymnkeep copy --from "Sat 5:30 PM" --to "Sun 8:00 AM" --replace
|
|
39
|
+
|
|
40
|
+
hymnkeep upload "Shepherd Me (SATB).pdf" --to "Shepherd Me, O God" --part SATB
|
|
41
|
+
hymnkeep upload melody.mp3 --to "Gather Us In" --for tune --part Melody
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A command acts on every service of the day unless `--at` names some. Every
|
|
45
|
+
command that changes something takes `--preview`, which says what it would do
|
|
46
|
+
and writes nothing. `--json` prints what Hymnkeep sent, for scripts.
|
|
47
|
+
|
|
48
|
+
`remove-slot` takes one place from each service. Where a service has the slot
|
|
49
|
+
more than once, it takes the empty one, or the one holding the piece
|
|
50
|
+
`--holding` names. `copy` onto a service that already has music needs
|
|
51
|
+
`--replace` (take the first service's music instead) or `--add` (put it after).
|
|
52
|
+
|
|
53
|
+
`upload` takes PDF and MP3 files up to 64 MB, onto a piece in your library.
|
|
54
|
+
`--for` puts the file on something the piece reads from instead of the piece
|
|
55
|
+
itself: its tune, its text, or one book's printing, by name.
|
|
56
|
+
|
|
57
|
+
## Any call
|
|
58
|
+
|
|
59
|
+
Everything else Hymnkeep can do is one call away. `hymnkeep api` lists the
|
|
60
|
+
calls, and `hymnkeep api <name> …` makes one:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
hymnkeep api occasions.index from=2026-11-01 to=2026-11-30
|
|
64
|
+
hymnkeep api music.library archived:=true q=gather
|
|
65
|
+
hymnkeep api services.slots.update occasion=<id> service=<id> slot=<id> piece_id=<id> 'spread[<service id>]=replace'
|
|
66
|
+
hymnkeep api music.files.store piece=<id> file=@score.pdf belongs_to=tune --preview
|
|
38
67
|
```
|
|
39
68
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
69
|
+
- `name=text` for text, and `name:=<json>` for a number, `true`, `null` or a list.
|
|
70
|
+
- `a[b]=value` for a nested field, and `a[]=value` to add to a list.
|
|
71
|
+
- `file=@path` for a file to upload; the call then goes as a form.
|
|
72
|
+
- `--data '<json>'` gives the whole body at once; pairs are added to it.
|
|
73
|
+
- `--preview` shows what would be sent, and sends nothing.
|
|
43
74
|
|
|
44
|
-
|
|
45
|
-
|
|
75
|
+
Ids go in the address, and the other fields where the call takes them. A field
|
|
76
|
+
the call does not list is still sent, with a note saying so.
|
|
46
77
|
|
|
47
78
|
## In an AI app
|
|
48
79
|
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,8 @@ import { stdin, stdout } from 'node:process';
|
|
|
5
5
|
import { HymnkeepError, Hymnkeep } from './client.js';
|
|
6
6
|
import { communityFrom, forget, load, save } from './config.js';
|
|
7
7
|
import { serve } from './mcp.js';
|
|
8
|
-
import {
|
|
8
|
+
import { describe, operation, operations, request, send } from './operations.js';
|
|
9
|
+
import { parsePairs } from './params.js';
|
|
9
10
|
import * as tasks from './tasks.js';
|
|
10
11
|
const program = new Command();
|
|
11
12
|
program
|
|
@@ -114,8 +115,13 @@ where(program
|
|
|
114
115
|
.description('Put a piece in a place at every service named, replacing what the place held.')
|
|
115
116
|
.option('--both', 'where the place holds something else, sing both instead of replacing')
|
|
116
117
|
.option('--preview', 'say what it would do, and write nothing')).action((slot, piece, o) => run(() => tasks.put(api(), { when: o.day, slot, piece: piece.join(' '), services: o.at, both: o.both, preview: o.preview })));
|
|
117
|
-
where(program.command('add-slot').argument('<slot>', 'a slot name').description('Add a slot, empty, to every service named.')).action((slot, o) => run(() => tasks.addSlot(api(), { when: o.day, slot, services: o.at })));
|
|
118
|
-
where(program
|
|
118
|
+
where(program.command('add-slot').argument('<slot>', 'a slot name').description('Add a slot, empty, to every service named.').option('--preview', 'say what it would do, and write nothing')).action((slot, o) => run(() => tasks.addSlot(api(), { when: o.day, slot, services: o.at, preview: o.preview })));
|
|
119
|
+
where(program
|
|
120
|
+
.command('remove-slot')
|
|
121
|
+
.argument('<slot>', 'a slot name')
|
|
122
|
+
.description('Take a slot, and whatever is in it, off every service named: one place a service.')
|
|
123
|
+
.option('--holding <piece>', 'where a service has the slot more than once, the one holding this piece')
|
|
124
|
+
.option('--preview', 'say what it would do, and write nothing')).action((slot, o) => run(() => tasks.removeSlot(api(), { when: o.day, slot, services: o.at, holding: o.holding, preview: o.preview })));
|
|
119
125
|
where(program
|
|
120
126
|
.command('assign')
|
|
121
127
|
.argument('<who...>', 'a person or an ensemble, by name')
|
|
@@ -127,21 +133,39 @@ where(program
|
|
|
127
133
|
.command('unassign')
|
|
128
134
|
.argument('<who...>', 'a person or an ensemble, by name')
|
|
129
135
|
.description('Take a person or an ensemble off every service named.')
|
|
130
|
-
.option('--job <job>', 'only where they do this job')
|
|
136
|
+
.option('--job <job>', 'only where they do this job')
|
|
137
|
+
.option('--preview', 'say what it would do, and write nothing')).action((who, o) => run(() => tasks.unassign(api(), { when: o.day, who: who.join(' '), job: o.job, services: o.at, preview: o.preview })));
|
|
131
138
|
program
|
|
132
139
|
.command('copy')
|
|
133
140
|
.description("Copy one service's music onto another of the same day.")
|
|
134
141
|
.requiredOption('--from <service>')
|
|
135
142
|
.requiredOption('--to <service>')
|
|
136
143
|
.option('--day <day>', 'a date or an occasion id; the next occasion if left out')
|
|
137
|
-
.option('--replace', "replace the second service's music
|
|
138
|
-
.
|
|
144
|
+
.option('--replace', "replace the second service's music with the first's")
|
|
145
|
+
.option('--add', "put the first service's music after what the second has")
|
|
146
|
+
.option('--preview', 'say what it would do, and write nothing')
|
|
147
|
+
.action((o) => run(() => tasks.copy(api(), { when: o.day, from: o.from, to: o.to, replace: o.replace, add: o.add, preview: o.preview })));
|
|
148
|
+
program
|
|
149
|
+
.command('upload')
|
|
150
|
+
.argument('<files...>', 'PDF or MP3 files, up to 64 MB each')
|
|
151
|
+
.requiredOption('--to <piece>', 'the piece in the library, by title or id')
|
|
152
|
+
.option('--for <place>', "where on the piece the file belongs: the tune, the text, or one book's printing (by its name); the piece itself if left out")
|
|
153
|
+
.option('--part <part>', 'a part the file holds ("Melody", "SATB"); repeat for more', collect)
|
|
154
|
+
.option('--preview', 'say what it would do, and send nothing')
|
|
155
|
+
.description('Upload scores and recordings to a piece in the library.')
|
|
156
|
+
.action((files, o) => run(() => tasks.upload(api(), { files, piece: o.to, place: o.for, parts: o.part, preview: o.preview })));
|
|
139
157
|
program
|
|
140
158
|
.command('api')
|
|
141
159
|
.argument('[call]', 'a call by name; every call is listed if left out')
|
|
142
|
-
.argument('[params...]', 'name=value for
|
|
160
|
+
.argument('[params...]', 'its ids and fields: name=text, name:=<json> for a number, true, null or a list, a[b]=value for a nested field, a[]=value to add to a list, file=@path for a file to upload')
|
|
143
161
|
.description('Any call the API offers, by name: the command-line door to everything.')
|
|
144
|
-
.option('--data <json>', 'the whole body of a write, as JSON')
|
|
162
|
+
.option('--data <json>', 'the whole body of a write, as JSON; name=value pairs are added to it')
|
|
163
|
+
.option('--preview', 'show what would be sent, and send nothing')
|
|
164
|
+
.addHelpText('after', `
|
|
165
|
+
Examples:
|
|
166
|
+
hymnkeep api occasions.index from=2026-11-01 to=2026-11-30
|
|
167
|
+
hymnkeep api music.library archived:=true q=gather
|
|
168
|
+
hymnkeep api music.files.store piece=<id> file=@score.pdf belongs_to=tune --preview`)
|
|
145
169
|
.action(async (name, params, o) => {
|
|
146
170
|
if (!name) {
|
|
147
171
|
for (const op of operations()) {
|
|
@@ -150,12 +174,18 @@ program
|
|
|
150
174
|
return;
|
|
151
175
|
}
|
|
152
176
|
const op = operation(name);
|
|
153
|
-
const values = o.data ? JSON.parse(o.data) : {};
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
177
|
+
const values = parsePairs(params, o.data ? JSON.parse(o.data) : {});
|
|
178
|
+
const known = new Set([...op.pathParams, ...op.queryParams.map((p) => p.name), ...Object.keys((op.body?.properties ?? {}))]);
|
|
179
|
+
const unknown = Object.keys(values).filter((field) => !known.has(field));
|
|
180
|
+
if (unknown.length) {
|
|
181
|
+
process.stderr.write(`Note: ${op.id} does not list ${unknown.join(', ')}; sending ${unknown.length === 1 ? 'it' : 'them'} anyway.\n`);
|
|
182
|
+
}
|
|
183
|
+
const laid = request(op, values);
|
|
184
|
+
if (o.preview) {
|
|
185
|
+
stdout.write(`${describe(laid, load()?.url ? api().base : '')}\n`);
|
|
186
|
+
return;
|
|
157
187
|
}
|
|
158
|
-
stdout.write(`${JSON.stringify(await
|
|
188
|
+
stdout.write(`${JSON.stringify(await send(api(), laid), null, 2)}\n`);
|
|
159
189
|
});
|
|
160
190
|
program
|
|
161
191
|
.command('mcp')
|
package/dist/client.js
CHANGED
|
@@ -23,9 +23,20 @@ export class Hymnkeep {
|
|
|
23
23
|
return this.credentials.community;
|
|
24
24
|
}
|
|
25
25
|
/** `path` is under `/api/p/{community}`, as `occasions` or `occasions/{id}`. */
|
|
26
|
+
/** Where calls go, for a preview to show. */
|
|
27
|
+
get base() {
|
|
28
|
+
return `${this.credentials.url}/api/p/${encodeURIComponent(this.credentials.community)}/`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `query` is either pairs already named as Laravel reads them, or a plain
|
|
32
|
+
* object; `form` is a form with files in it, sent instead of a JSON body.
|
|
33
|
+
*/
|
|
26
34
|
async request(method, path, options = {}) {
|
|
27
|
-
const url = new URL(`${this.
|
|
28
|
-
|
|
35
|
+
const url = new URL(`${this.base}${path.replace(/^\/+/, '')}`);
|
|
36
|
+
if (Array.isArray(options.query)) {
|
|
37
|
+
options.query.forEach(([name, value]) => url.searchParams.append(name, value));
|
|
38
|
+
}
|
|
39
|
+
for (const [name, value] of Array.isArray(options.query) ? [] : Object.entries(options.query ?? {})) {
|
|
29
40
|
if (value === undefined || value === null) {
|
|
30
41
|
continue;
|
|
31
42
|
}
|
|
@@ -33,7 +44,7 @@ export class Hymnkeep {
|
|
|
33
44
|
value.forEach((each) => url.searchParams.append(`${name}[]`, String(each)));
|
|
34
45
|
}
|
|
35
46
|
else {
|
|
36
|
-
url.searchParams.set(name, String(value));
|
|
47
|
+
url.searchParams.set(name, typeof value === 'boolean' ? (value ? '1' : '0') : String(value));
|
|
37
48
|
}
|
|
38
49
|
}
|
|
39
50
|
const response = await fetch(url, {
|
|
@@ -41,10 +52,10 @@ export class Hymnkeep {
|
|
|
41
52
|
headers: {
|
|
42
53
|
Authorization: `Bearer ${this.credentials.key}`,
|
|
43
54
|
Accept: 'application/json',
|
|
44
|
-
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
55
|
+
...(options.body === undefined || options.form ? {} : { 'Content-Type': 'application/json' }),
|
|
45
56
|
'User-Agent': 'hymnkeep-cli',
|
|
46
57
|
},
|
|
47
|
-
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
58
|
+
body: options.form ?? (options.body === undefined ? undefined : JSON.stringify(options.body)),
|
|
48
59
|
});
|
|
49
60
|
const text = await response.text();
|
|
50
61
|
const json = text === '' ? null : safeJson(text);
|
package/dist/mcp.js
CHANGED
|
@@ -2,7 +2,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { HymnkeepError } from './client.js';
|
|
5
|
-
import {
|
|
5
|
+
import { describe, operation, operations, request, send } from './operations.js';
|
|
6
|
+
import { Upload } from './params.js';
|
|
6
7
|
import * as tasks from './tasks.js';
|
|
7
8
|
const day = z.string().optional().describe('A date (YYYY-MM-DD) or an occasion id. The next occasion if left out.');
|
|
8
9
|
const at = z.array(z.string()).optional().describe('Services by their time or name ("Sun 8:00 AM", "Vigil"). Every service of the day if left out.');
|
|
@@ -27,7 +28,7 @@ async function reply(task) {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
export function build(api) {
|
|
30
|
-
const server = new McpServer({ name: 'hymnkeep', version: '0.1.
|
|
31
|
+
const server = new McpServer({ name: 'hymnkeep', version: '0.1.2' }, { instructions: INSTRUCTIONS });
|
|
31
32
|
server.registerTool('whoami', { description: 'Who this key is: the community, the person, their role, and whether it may write.', annotations: READ }, () => reply(() => tasks.whoami(api)));
|
|
32
33
|
server.registerTool('week', { description: "An occasion's services, its music place by place (where services differ, each one's), and who is on each service.", inputSchema: { day }, annotations: READ }, ({ day: when }) => reply(() => tasks.show(api, when)));
|
|
33
34
|
server.registerTool('occasions', {
|
|
@@ -45,7 +46,7 @@ export function build(api) {
|
|
|
45
46
|
inputSchema: {
|
|
46
47
|
date: z.string().describe('YYYY-MM-DD'),
|
|
47
48
|
templates: z.array(z.string()).optional().describe('Template ids. The ones the day ticks itself if left out.'),
|
|
48
|
-
answers: z.record(z.string()).optional().describe("Each question's key and the chosen option's value."),
|
|
49
|
+
answers: z.record(z.string(), z.string()).optional().describe("Each question's key and the chosen option's value."),
|
|
49
50
|
preview,
|
|
50
51
|
},
|
|
51
52
|
annotations: WRITE,
|
|
@@ -64,10 +65,14 @@ export function build(api) {
|
|
|
64
65
|
}, ({ day: when, slot, piece, services, both, preview: look }) => reply(() => tasks.put(api, { when, slot, piece, services, both, preview: look })));
|
|
65
66
|
server.registerTool('add_slot', {
|
|
66
67
|
description: 'Add a slot, empty, to the services named, at the end of each one\'s list.',
|
|
67
|
-
inputSchema: { day, slot: z.string().describe("A slot name from the community's list."), services: at },
|
|
68
|
+
inputSchema: { day, slot: z.string().describe("A slot name from the community's list."), services: at, preview },
|
|
68
69
|
annotations: WRITE,
|
|
69
|
-
}, ({ day: when, slot, services }) => reply(() => tasks.addSlot(api, { when, slot, services })));
|
|
70
|
-
server.registerTool('remove_slot', {
|
|
70
|
+
}, ({ day: when, slot, services, preview: look }) => reply(() => tasks.addSlot(api, { when, slot, services, preview: look })));
|
|
71
|
+
server.registerTool('remove_slot', {
|
|
72
|
+
description: 'Take a slot, and whatever is in it, off the services named: one place a service. Where a service has the slot twice, the empty one goes, or the one holding the piece named.',
|
|
73
|
+
inputSchema: { day, slot: z.string(), services: at, holding: z.string().optional().describe('The piece in the place to take, where a service has the slot more than once.'), preview },
|
|
74
|
+
annotations: REMOVE,
|
|
75
|
+
}, ({ day: when, slot, services, holding, preview: look }) => reply(() => tasks.removeSlot(api, { when, slot, services, holding, preview: look })));
|
|
71
76
|
server.registerTool('assign_musician', {
|
|
72
77
|
description: 'Assign a person to a job, or an ensemble, at the services named. Where someone else does that job, they are replaced unless both is true.',
|
|
73
78
|
inputSchema: {
|
|
@@ -80,12 +85,19 @@ export function build(api) {
|
|
|
80
85
|
},
|
|
81
86
|
annotations: WRITE,
|
|
82
87
|
}, ({ day: when, who, job, services, both, preview: look }) => reply(() => tasks.assign(api, { when, who, job, services, both, preview: look })));
|
|
83
|
-
server.registerTool('unassign_musician', { description: 'Take a person or an ensemble off the services named.', inputSchema: { day, who: z.string(), job: z.string().optional(), services: at }, annotations: REMOVE }, ({ day: when, who, job, services }) => reply(() => tasks.unassign(api, { when, who, job, services })));
|
|
88
|
+
server.registerTool('unassign_musician', { description: 'Take a person or an ensemble off the services named.', inputSchema: { day, who: z.string(), job: z.string().optional(), services: at, preview }, annotations: REMOVE }, ({ day: when, who, job, services, preview: look }) => reply(() => tasks.unassign(api, { when, who, job, services, preview: look })));
|
|
84
89
|
server.registerTool('copy_music', {
|
|
85
90
|
description: "Copy one service's music onto another of the same day, after what it has, or replacing it.",
|
|
86
|
-
inputSchema: {
|
|
91
|
+
inputSchema: {
|
|
92
|
+
day,
|
|
93
|
+
from: z.string(),
|
|
94
|
+
to: z.string(),
|
|
95
|
+
replace: z.boolean().optional().describe("Take the first service's music instead of the second's."),
|
|
96
|
+
add: z.boolean().optional().describe("Put the first service's music after the second's. One of the two is needed when the second has music."),
|
|
97
|
+
preview,
|
|
98
|
+
},
|
|
87
99
|
annotations: WRITE,
|
|
88
|
-
}, ({ day: when, from, to, replace }) => reply(() => tasks.copy(api, { when, from, to, replace })));
|
|
100
|
+
}, ({ day: when, from, to, replace, add, preview: look }) => reply(() => tasks.copy(api, { when, from, to, replace, add, preview: look })));
|
|
89
101
|
server.registerTool('api_calls', {
|
|
90
102
|
description: 'Every call the Hymnkeep API offers, by name, for what the other tools do not cover. Filter by words in the name or summary.',
|
|
91
103
|
inputSchema: { search: z.string().optional() },
|
|
@@ -98,15 +110,46 @@ export function build(api) {
|
|
|
98
110
|
};
|
|
99
111
|
}));
|
|
100
112
|
server.registerTool('api_call', {
|
|
101
|
-
description: 'Make one API call by name (see api_calls), with its ids and fields in params. A call whose method is not GET writes:
|
|
102
|
-
inputSchema: { name: z.string(), params: z.record(z.unknown()).optional() },
|
|
113
|
+
description: 'Make one API call by name (see api_calls), with its ids and fields in params. A field that takes a file takes {"file": "<path on this computer>"}. A call whose method is not GET writes: use preview first and show the person.',
|
|
114
|
+
inputSchema: { name: z.string(), params: z.record(z.string(), z.unknown()).optional(), preview },
|
|
103
115
|
annotations: WRITE,
|
|
104
|
-
}, ({ name, params }) => reply(async () => {
|
|
105
|
-
const
|
|
106
|
-
|
|
116
|
+
}, ({ name, params, preview: look }) => reply(async () => {
|
|
117
|
+
const laid = request(operation(name), withFiles(params ?? {}));
|
|
118
|
+
if (look) {
|
|
119
|
+
return { data: laid, text: describe(laid, api.base) };
|
|
120
|
+
}
|
|
121
|
+
return { data: await send(api, laid), text: `${name} done.` };
|
|
107
122
|
}));
|
|
123
|
+
server.registerTool('upload_file', {
|
|
124
|
+
description: "Upload a PDF or MP3 (up to 64 MB) from this computer to a piece in the library: on the piece, or on its tune, its text or one book's printing, with the parts it holds.",
|
|
125
|
+
inputSchema: {
|
|
126
|
+
path: z.string().describe('The file on this computer.'),
|
|
127
|
+
piece: z.string().describe('The piece in the library, by title or id.'),
|
|
128
|
+
place: z.string().optional().describe("Where on the piece: the tune, the text, or a book's printing, by name. The piece itself if left out."),
|
|
129
|
+
parts: z.array(z.string()).optional().describe('Parts the file holds, by name ("Melody", "SATB").'),
|
|
130
|
+
preview,
|
|
131
|
+
},
|
|
132
|
+
annotations: WRITE,
|
|
133
|
+
}, ({ path, piece, place, parts, preview: look }) => reply(() => tasks.upload(api, { files: [path], piece, place, parts, preview: look })));
|
|
108
134
|
return server;
|
|
109
135
|
}
|
|
136
|
+
/** `{"file": "<path>"}` in a call's params is a file to send. */
|
|
137
|
+
function withFiles(params) {
|
|
138
|
+
const walk = (value) => {
|
|
139
|
+
if (Array.isArray(value)) {
|
|
140
|
+
return value.map(walk);
|
|
141
|
+
}
|
|
142
|
+
if (value && typeof value === 'object') {
|
|
143
|
+
const entries = Object.entries(value);
|
|
144
|
+
if (entries.length === 1 && entries[0][0] === 'file' && typeof entries[0][1] === 'string') {
|
|
145
|
+
return new Upload(entries[0][1]);
|
|
146
|
+
}
|
|
147
|
+
return Object.fromEntries(entries.map(([key, each]) => [key, walk(each)]));
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
};
|
|
151
|
+
return walk(params);
|
|
152
|
+
}
|
|
110
153
|
export async function serve(api) {
|
|
111
154
|
await build(api).connect(new StdioServerTransport());
|
|
112
155
|
}
|
package/dist/operations.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { fit, flatten, hasUpload, Upload } from './params.js';
|
|
4
5
|
const PREFIX = '/api/p/{community}';
|
|
5
6
|
let cached = null;
|
|
6
7
|
/** Every call, read from the description that ships beside the program. */
|
|
@@ -18,7 +19,8 @@ export function operations() {
|
|
|
18
19
|
tag: op.tags[0] ?? '',
|
|
19
20
|
pathParams: (op.parameters ?? []).filter((p) => p.in === 'path' && p.name !== 'community').map((p) => p.name),
|
|
20
21
|
queryParams: (op.parameters ?? []).filter((p) => p.in === 'query').map((p) => ({ name: p.name.replace(/\[\]$/, ''), required: p.required, schema: p.schema })),
|
|
21
|
-
body: op.requestBody
|
|
22
|
+
body: op.requestBody ? (Object.values(op.requestBody.content)[0]?.schema ?? null) : null,
|
|
23
|
+
form: op.requestBody?.content['multipart/form-data'] !== undefined,
|
|
22
24
|
writes: method !== 'get',
|
|
23
25
|
})));
|
|
24
26
|
return cached;
|
|
@@ -31,10 +33,12 @@ export function operation(id) {
|
|
|
31
33
|
return found;
|
|
32
34
|
}
|
|
33
35
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
+
* Lays one call out: the path's ids from `params`, then each field where the
|
|
37
|
+
* description puts it, in the query or the body. A field the description
|
|
38
|
+
* does not know goes in the query of a read and the body of a write. A body
|
|
39
|
+
* with a file in it goes as a form.
|
|
36
40
|
*/
|
|
37
|
-
export
|
|
41
|
+
export function request(op, params) {
|
|
38
42
|
const rest = { ...params };
|
|
39
43
|
let path = op.path;
|
|
40
44
|
for (const name of op.pathParams) {
|
|
@@ -45,8 +49,68 @@ export async function call(api, op, params) {
|
|
|
45
49
|
path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
|
|
46
50
|
delete rest[name];
|
|
47
51
|
}
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
const query = {};
|
|
53
|
+
const body = {};
|
|
54
|
+
const fields = (op.body?.properties ?? {});
|
|
55
|
+
for (const [name, value] of Object.entries(rest)) {
|
|
56
|
+
const inQuery = op.queryParams.find((param) => param.name === name);
|
|
57
|
+
if (inQuery) {
|
|
58
|
+
query[name] = fit(value, inQuery.schema);
|
|
59
|
+
}
|
|
60
|
+
else if (name in fields || (op.writes && op.method !== 'DELETE') || op.body !== null) {
|
|
61
|
+
body[name] = fit(value, fields[name]);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
query[name] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const hasBody = op.body !== null || Object.keys(body).length > 0;
|
|
68
|
+
return {
|
|
69
|
+
method: op.method,
|
|
70
|
+
path,
|
|
71
|
+
query: flatten(query).map(([name, value]) => [name, value instanceof Upload ? value.path : value]),
|
|
72
|
+
body: hasBody ? body : null,
|
|
73
|
+
form: op.form || hasUpload(body),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Sends a call laid out by `request`. */
|
|
77
|
+
export function send(api, laid) {
|
|
78
|
+
if (laid.form && laid.body) {
|
|
79
|
+
const form = new FormData();
|
|
80
|
+
for (const [name, value] of flatten(laid.body)) {
|
|
81
|
+
if (value instanceof Upload) {
|
|
82
|
+
form.append(name, value.blob(), value.name);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
form.append(name, value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// A form only goes by POST; Laravel reads PATCH and PUT forms through `_method`.
|
|
89
|
+
if (laid.method !== 'POST') {
|
|
90
|
+
form.append('_method', laid.method);
|
|
91
|
+
}
|
|
92
|
+
return api.request('POST', laid.path, { query: laid.query, form });
|
|
93
|
+
}
|
|
94
|
+
return api.request(laid.method, laid.path, { query: laid.query, body: laid.body ?? undefined });
|
|
95
|
+
}
|
|
96
|
+
/** What a call would send, for a person to read before it is sent. */
|
|
97
|
+
export function describe(laid, base = '') {
|
|
98
|
+
const query = laid.query.length ? `?${laid.query.map(([n, v]) => `${n}=${encodeURIComponent(v)}`).join('&')}` : '';
|
|
99
|
+
const lines = [`Would send ${laid.method} ${base}${laid.path}${query}`];
|
|
100
|
+
if (laid.body) {
|
|
101
|
+
if (laid.form) {
|
|
102
|
+
for (const [name, value] of flatten(laid.body)) {
|
|
103
|
+
lines.push(value instanceof Upload ? ` ${name}: the file ${value.path} (${Math.ceil(value.size / 1024)} KB, ${value.type})` : ` ${name}: ${value}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
lines.push(JSON.stringify(laid.body, null, 2).replace(/^/gm, ' '));
|
|
108
|
+
}
|
|
50
109
|
}
|
|
51
|
-
|
|
110
|
+
lines.push('Nothing was sent.');
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
/** Makes one call. */
|
|
114
|
+
export function call(api, op, params) {
|
|
115
|
+
return send(api, request(op, params));
|
|
52
116
|
}
|
package/dist/params.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, extname } from 'node:path';
|
|
3
|
+
/** A file named on the command line as `field=@path`, read only when it is sent. */
|
|
4
|
+
export class Upload {
|
|
5
|
+
path;
|
|
6
|
+
constructor(path) {
|
|
7
|
+
this.path = path;
|
|
8
|
+
}
|
|
9
|
+
get name() {
|
|
10
|
+
return basename(this.path);
|
|
11
|
+
}
|
|
12
|
+
get size() {
|
|
13
|
+
return statSync(this.path).size;
|
|
14
|
+
}
|
|
15
|
+
get type() {
|
|
16
|
+
return { '.pdf': 'application/pdf', '.mp3': 'audio/mpeg' }[extname(this.path).toLowerCase()] ?? 'application/octet-stream';
|
|
17
|
+
}
|
|
18
|
+
blob() {
|
|
19
|
+
return new Blob([readFileSync(this.path)], { type: this.type });
|
|
20
|
+
}
|
|
21
|
+
toJSON() {
|
|
22
|
+
return `@${this.path}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reads `name=value` pairs as the old HQ tool did: `key=text`, `key:=<json>`
|
|
27
|
+
* for a number, true, null or a list, `a[b]=value` for a nested field,
|
|
28
|
+
* `a[]=value` to add to a list, and `file=@path` for a file to upload.
|
|
29
|
+
*/
|
|
30
|
+
export function parsePairs(pairs, into = {}) {
|
|
31
|
+
for (const pair of pairs) {
|
|
32
|
+
const json = pair.match(/^([^=:]+):=(.*)$/s);
|
|
33
|
+
const text = json ? null : pair.match(/^([^=]+)=(.*)$/s);
|
|
34
|
+
if (!json && !text) {
|
|
35
|
+
throw new Error(`"${pair}" needs a value: name=value, or name:=<json> for a number, true or a list.`);
|
|
36
|
+
}
|
|
37
|
+
const [, key, raw] = (json ?? text);
|
|
38
|
+
let value;
|
|
39
|
+
if (json) {
|
|
40
|
+
try {
|
|
41
|
+
value = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error(`"${key}:=" takes JSON, and ${raw} is not JSON. For text, write ${key}=${raw}.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
value = raw.startsWith('@') && raw.length > 1 ? upload(raw.slice(1)) : raw;
|
|
49
|
+
}
|
|
50
|
+
place(into, path(key), value);
|
|
51
|
+
}
|
|
52
|
+
return into;
|
|
53
|
+
}
|
|
54
|
+
function upload(file) {
|
|
55
|
+
try {
|
|
56
|
+
statSync(file);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`There is no file at ${file}.`);
|
|
60
|
+
}
|
|
61
|
+
return new Upload(file);
|
|
62
|
+
}
|
|
63
|
+
/** `a[b][]` → ['a', 'b', '']. */
|
|
64
|
+
function path(key) {
|
|
65
|
+
const match = key.match(/^([^[\]]+)((?:\[[^[\]]*\])*)$/);
|
|
66
|
+
if (!match) {
|
|
67
|
+
throw new Error(`"${key}" is not a field name: write a, a[b] or a[].`);
|
|
68
|
+
}
|
|
69
|
+
return [match[1], ...[...match[2].matchAll(/\[([^[\]]*)\]/g)].map((m) => m[1])];
|
|
70
|
+
}
|
|
71
|
+
function place(into, keys, value) {
|
|
72
|
+
const [head, ...rest] = keys;
|
|
73
|
+
const container = into;
|
|
74
|
+
if (rest.length === 0) {
|
|
75
|
+
if (head === '' && Array.isArray(into)) {
|
|
76
|
+
into.push(value);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
container[head] = value;
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const next = rest[0] === '' ? [] : {};
|
|
84
|
+
if (head === '' && Array.isArray(into)) {
|
|
85
|
+
into.push(next);
|
|
86
|
+
place(next, rest, value);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (container[head] === undefined || typeof container[head] !== 'object' || container[head] === null) {
|
|
90
|
+
container[head] = next;
|
|
91
|
+
}
|
|
92
|
+
place(container[head], rest, value);
|
|
93
|
+
}
|
|
94
|
+
const typeOf = (schema) => (schema?.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type]);
|
|
95
|
+
/**
|
|
96
|
+
* Fits what was typed to what the call takes: one value where a list is
|
|
97
|
+
* wanted becomes a list of one, and "true", "false" and numbers typed as
|
|
98
|
+
* text become what the field expects.
|
|
99
|
+
*/
|
|
100
|
+
export function fit(value, schema) {
|
|
101
|
+
if (schema === undefined || value === null || value instanceof Upload) {
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
const types = typeOf(schema);
|
|
105
|
+
if (types.includes('array') && !Array.isArray(value)) {
|
|
106
|
+
return [fit(value, schema.items)];
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
return value.map((each) => fit(each, schema.items));
|
|
110
|
+
}
|
|
111
|
+
if (typeof value === 'object') {
|
|
112
|
+
return Object.fromEntries(Object.entries(value).map(([key, each]) => [key, fit(each, schema.properties?.[key])]));
|
|
113
|
+
}
|
|
114
|
+
if (typeof value === 'string') {
|
|
115
|
+
if (types.includes('boolean') && !types.includes('string') && /^(true|false|1|0|yes|no)$/i.test(value)) {
|
|
116
|
+
return /^(true|1|yes)$/i.test(value);
|
|
117
|
+
}
|
|
118
|
+
if ((types.includes('integer') || types.includes('number')) && !types.includes('string') && value.trim() !== '' && Number.isFinite(Number(value))) {
|
|
119
|
+
return Number(value);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
export function hasUpload(value) {
|
|
125
|
+
if (value instanceof Upload) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
return value.some(hasUpload);
|
|
130
|
+
}
|
|
131
|
+
return typeof value === 'object' && value !== null && Object.values(value).some(hasUpload);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Flattens a value into bracketed names, `a[0]`, `a[b]`, as Laravel reads a
|
|
135
|
+
* query string or a form. True and false go as 1 and 0, which is the only
|
|
136
|
+
* spelling its boolean rule takes outside JSON.
|
|
137
|
+
*/
|
|
138
|
+
export function flatten(values) {
|
|
139
|
+
const out = [];
|
|
140
|
+
const walk = (name, value) => {
|
|
141
|
+
if (value === null || value === undefined) {
|
|
142
|
+
out.push([name, '']);
|
|
143
|
+
}
|
|
144
|
+
else if (value instanceof Upload) {
|
|
145
|
+
out.push([name, value]);
|
|
146
|
+
}
|
|
147
|
+
else if (Array.isArray(value)) {
|
|
148
|
+
value.forEach((each, index) => walk(`${name}[${index}]`, each));
|
|
149
|
+
}
|
|
150
|
+
else if (typeof value === 'object') {
|
|
151
|
+
for (const [key, each] of Object.entries(value)) {
|
|
152
|
+
walk(`${name}[${key}]`, each);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else if (typeof value === 'boolean') {
|
|
156
|
+
out.push([name, value ? '1' : '0']);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
out.push([name, String(value)]);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
for (const [key, value] of Object.entries(values)) {
|
|
163
|
+
walk(key, value);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hymnkeep",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Hymnkeep from the command line, and as an MCP server for AI apps.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/SirRotsen/hymnkeep-cli.git"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"bin": {
|
|
8
12
|
"hymnkeep": "dist/cli.js"
|
|
@@ -25,7 +29,7 @@
|
|
|
25
29
|
"dependencies": {
|
|
26
30
|
"@modelcontextprotocol/sdk": "1.32.2",
|
|
27
31
|
"commander": "15.0.0",
|
|
28
|
-
"zod": "
|
|
32
|
+
"zod": "4.6.7"
|
|
29
33
|
},
|
|
30
34
|
"devDependencies": {
|
|
31
35
|
"@types/node": "22.20.5",
|