1import { execFile } from "node:child_process";
2import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { dirname, join, resolve } from "node:path";
5import process from "node:process";
6import { fileURLToPath } from "node:url";
7import { promisify } from "node:util";
8
9type RawReaperAction = {
10 commandId: number;
11 name: string;
12};
13
14type GeneratedReaperAction = RawReaperAction & {
15 actionId: string;
16};
17
18const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER";
19const srcDir = dirname(fileURLToPath(import.meta.url));
20const repoRoot = resolve(srcDir, "..", "..");
21const enumeratorPath = join(
22 repoRoot,
23 "config",
24 "scripts",
25 "reaper",
26 "enumerate_actions.lua",
27);
28const outputPath = join(srcDir, "actions.ts");
29const execFileAsync = promisify(execFile);
30
31async function main() {
32 const tempDir = await mkdtemp(join(tmpdir(), "reaper-actions-"));
33 const dumpPath = join(tempDir, "actions.json");
34 const runnerPath = join(tempDir, "run_enumerator.lua");
35
36 try {
37 await writeFile(runnerPath, buildRunnerScript(dumpPath), "utf8");
38 await execFileAsync(
39 process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN,
40 ["-nonewinst", runnerPath],
41 );
42 await waitForFile(dumpPath);
43
44 const rawActions = parseRawActions(
45 JSON.parse(await readFile(dumpPath, "utf8")) as unknown,
46 );
47 const actions = buildActionMap(rawActions);
48 await mkdir(dirname(outputPath), { recursive: true });
49 await writeFile(outputPath, renderActionsFile(actions), "utf8");
50
51 console.info(
52 `Generated ${actions.length} REAPER actions in src/Reaper/actions.ts`,
53 );
54 } finally {
55 await rm(tempDir, { force: true, recursive: true });
56 }
57}
58
59function buildRunnerScript(dumpPath: string) {
60 return [
61 `OUTPUT_PATH = ${JSON.stringify(dumpPath)}`,
62 `dofile(${JSON.stringify(enumeratorPath)})`,
63 "",
64 ].join("\n");
65}
66
67function parseRawActions(value: unknown): RawReaperAction[] {
68 if (!Array.isArray(value)) {
69 throw new Error("Expected REAPER action dump to be an array.");
70 }
71
72 return value.flatMap((entry) => {
73 if (
74 !entry
75 || typeof entry !== "object"
76 || !("commandId" in entry)
77 || !("name" in entry)
78 ) {
79 return [];
80 }
81
82 const commandId = Number(entry.commandId);
83 const name = String(entry.name ?? "").trim();
84 if (!Number.isInteger(commandId) || name === "") {
85 return [];
86 }
87
88 return [{ commandId, name }];
89 });
90}
91
92function buildActionMap(actions: ReadonlyArray<RawReaperAction>) {
93 const usedIds = new Set<string>();
94
95 return [...actions]
96 .map((action) => ({
97 ...action,
98 actionId: createUniqueActionId(action.name, action.commandId, usedIds),
99 }))
100 .sort((left, right) => left.actionId.localeCompare(right.actionId));
101}
102
103function createUniqueActionId(
104 name: string,
105 commandId: number,
106 usedIds: Set<string>,
107) {
108 const baseId = toKebabCase(name) || `action-${commandId}`;
109 let actionId = baseId;
110 let duplicateIndex = 2;
111
112 while (usedIds.has(actionId)) {
113 actionId = `${baseId}-${commandId}`;
114 if (!usedIds.has(actionId)) {
115 break;
116 }
117 actionId = `${baseId}-${commandId}-${duplicateIndex}`;
118 duplicateIndex += 1;
119 }
120
121 usedIds.add(actionId);
122 return actionId;
123}
124
125function toKebabCase(value: string) {
126 return value
127 .normalize("NFKD")
128 .replace(/[\u0300-\u036f]/gu, "")
129 .toLowerCase()
130 .replace(/&/gu, " and ")
131 .replace(/\+/gu, " plus ")
132 .replace(/#/gu, " number ")
133 .replace(/%/gu, " percent ")
134 .replace(/[^a-z0-9]+/gu, "-")
135 .replace(/^-+|-+$/gu, "")
136 .replace(/-{2,}/gu, "-");
137}
138
139function renderActionsFile(actions: ReadonlyArray<GeneratedReaperAction>) {
140 const lines = [
141 "// Generated by src/Reaper/generate-actions.ts",
142 "// Source: REAPER main action section via kbd_enumerateActions()/kbd_getTextFromCmd().",
143 "",
144 "export const REAPER_ACTIONS = {",
145 ...actions.map((action) => ` ${JSON.stringify(action.actionId)}: ${action.commandId},`),
146 "} as const satisfies Record<string, number>;",
147 "",
148 "export type ReaperActionId = keyof typeof REAPER_ACTIONS;",
149 "",
150 ];
151
152 return lines.join("\n");
153}
154
155async function waitForFile(filePath: string, timeoutMs = 30_000) {
156 const startedAt = Date.now();
157
158 while (Date.now() - startedAt < timeoutMs) {
159 try {
160 await access(filePath);
161 return;
162 } catch {}
163
164 await sleep(200);
165 }
166
167 throw new Error(`Timed out waiting for ${filePath}`);
168}
169
170function sleep(ms: number) {
171 return new Promise<void>((resolve) => {
172 setTimeout(resolve, ms);
173 });
174}
175
176await main();