From 7cbf1551d8df53a1f9a87aedee87fce8afff9c79 Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Thu, 16 Apr 2026 09:18:28 +0900 Subject: [PATCH 1/8] =?UTF-8?q?CLI=20=E3=81=AE=20`.mxl`=20/=20`.mscz`=20fi?= =?UTF-8?q?le=20I/O=20=E5=AF=BE=E5=BF=9C=E3=81=A8=20ZIP=20=E5=87=A6?= =?UTF-8?q?=E7=90=86=E3=81=AE=E5=85=B1=E9=80=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 CLI で MusicXML / MuseScore の圧縮形式を file I/O に限って扱えるようにします。あわせて、既存の ZIP 読み書き処理を共通 helper に整理し、CLI と既存処理の再利用性を高めます。 ## 変更内容 - CLI で `--from musicxml` の入力として `.mxl` を扱えるように変更 - CLI で `--from musescore` の入力として `.mscz` を扱えるように変更 - CLI で `--to musicxml` の出力先が `.mxl` の場合に圧縮 MusicXML を出力するように変更 - CLI で `--to musescore` の出力先が `.mscz` の場合に圧縮 MuseScore を出力するように変更 - `stdin` / `stdout` は従来どおり text-only のまま維持 - ZIP 読み書き処理を `src/ts/zip-io.ts` に共通化 - `src/ts/mxl-io.ts` と `src/ts/download-flow.ts` から ZIP 共通 helper を利用する構成に整理 - `src/ts/cli-api.ts` に CLI 向けの ZIP 入出力 helper を追加 - `scripts/mikuscore-cli.mjs` の help 文面を ZIP file I/O 対応に合わせて更新 - `README.md`、`docs/DEVELOPMENT.md`、`docs/spec/CLI_STEP1.md`、`docs/future/CLI_ROADMAP.md`、`TODO.md` を更新 ## テスト - `tests/unit/cli-api.spec.ts` に `.mxl` / `.mscz` の encode / decode テストを追加 - `tests/unit/mikuscore-cli.spec.ts` に `.mxl` / `.mscz` の CLI file input / output テストを追加 ## 補足 - `index.html`、`mikuscore.html`、`src/js/main.js` は関連変更に伴う更新を含みます - `TODO.md` には、今回の対応完了に加えて、重い `musescore-io` roundtrip テストの見直しタスクが追記されています --- README.md | 3 + TODO.md | 72 ++++- docs/DEVELOPMENT.md | 9 +- docs/future/CLI_ROADMAP.md | 4 +- docs/spec/CLI_STEP1.md | 5 + index.html | 2 +- mikuscore.html | 420 ++++++++++++++------------- scripts/mikuscore-cli.mjs | 57 +++- src/js/main.js | 420 ++++++++++++++------------- src/ts/cli-api.ts | 95 ++++++- src/ts/download-flow.ts | 225 +-------------- src/ts/mxl-io.ts | 259 +---------------- src/ts/zip-io.ts | 471 +++++++++++++++++++++++++++++++ tests/unit/cli-api.spec.ts | 53 +++- tests/unit/mikuscore-cli.spec.ts | 50 +++- 15 files changed, 1260 insertions(+), 885 deletions(-) create mode 100644 src/ts/zip-io.ts diff --git a/README.md b/README.md index 199a1e7..1a041f6 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ Examples: - `npm run cli -- convert --from musicxml --to midi --in score.musicxml --out score.mid` - `npm run cli -- convert --from musescore --to musicxml --in score.mscx --out score.musicxml` - `npm run cli -- convert --from musicxml --to musescore --in score.musicxml --out score.mscx` +- `npm run cli -- convert --from musicxml --to abc --in score.mxl --out score.abc` +- `npm run cli -- convert --from musescore --to musicxml --in score.mscz --out score.mxl` +- `npm run cli -- convert --from musicxml --to musescore --in score.musicxml --out score.mscz` - `npm run cli -- render svg --in score.musicxml --out score.svg` For CLI and development details, see `docs/DEVELOPMENT.md` and `docs/spec/CLI_STEP1.md`. diff --git a/TODO.md b/TODO.md index 80d9061..b795c2c 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ ## CLI -- [ ] Document `convert`-first CLI naming consistently in all current-facing docs. +- [x] Document `convert`-first CLI naming consistently in all current-facing docs. - Recheck `README.md`, `docs/spec/CLI_STEP1.md`, and future notes after the command surface stabilizes. - Keep `import/export` as internal facade wording only, not CLI wording. @@ -26,17 +26,68 @@ - Next checks: - decide whether CLI needs MIDI export options such as profile / metadata toggles -- [ ] Implement Step 3 conversion/render pairs. +- [x] Implement Step 3 conversion/render pairs. - Current first cut exists for: - `mikuscore convert --from musescore --to musicxml` - `mikuscore convert --from musicxml --to musescore` - `mikuscore render svg` - Next checks: - - add explicit CLI support decision/work for compressed `.mscz` - - decide whether CLI should handle compressed `.mscz` directly or remain `.mscx`-text only + - expand file I/O support so `--from musicxml` can read `.mxl` + - expand file I/O support so `--from musescore` can read `.mscz` + - expand file I/O support so `--to musicxml` can write `.mxl` when `--out` ends with `.mxl` + - expand file I/O support so `--to musescore` can write `.mscz` when `--out` ends with `.mscz` + - keep `stdin` / `stdout` text-only for `musicxml` and `musescore`; ZIP support should apply to file paths only + - move ZIP read/write behavior into reusable non-CLI helpers instead of adding ad hoc CLI-only logic - decide whether render options such as scale / page size should become CLI flags -- [ ] Expand CLI tests together with each new conversion pair. +- [x] Formalize CLI ZIP file I/O support for MusicXML and MuseScore. + - Goal: + - support `.mxl` and `.mscz` in CLI file input/output without changing the text-only `stdin` / `stdout` contract + - Work breakdown: + - [x] Freeze the CLI ZIP I/O contract in docs/TODO notes before code movement. + - ZIP behavior applies only when `--in` / `--out` are file paths. + - `stdin` / `stdout` stay text-only for `musicxml` and `musescore`. + - extension-based handling is limited to `.mxl` and `.mscz`, not generic format auto-detection. + - [x] Extract ZIP container primitives from UI-oriented code into reusable helpers. + - split reusable ZIP read/write logic away from browser/download-specific payload code + - keep helpers suitable for both app-side and CLI-side callers + - [x] Make ZIP import helpers explicitly reusable for CLI file reads. + - cover `.mxl -> MusicXML text` + - cover `.mscz -> MuseScore text` + - keep plain `.musicxml` / `.xml` / `.mscx` reads unchanged + - [x] Make ZIP export helpers explicitly reusable for CLI file writes. + - cover `MusicXML text -> .mxl bytes` + - cover `MuseScore text -> .mscz bytes` + - keep plain `.musicxml` / `.xml` / `.mscx` writes unchanged + - [x] Refactor the CLI script to route file input through extension-aware readers. + - `mikuscore convert --from musicxml --in score.mxl ...` + - `mikuscore convert --from musescore --in score.mscz ...` + - keep stdin path on the current text-only reader + - [x] Refactor the CLI script to route file output through extension-aware writers. + - `mikuscore convert --to musicxml --out score.mxl ...` + - `mikuscore convert --to musescore --out score.mscz ...` + - keep stdout path on the current text/binary writer behavior + - [x] Add focused facade/API coverage around reusable ZIP helpers if the seam moves into `src/ts`. + - avoid pushing ZIP branching back into `scripts/mikuscore-cli.mjs` + - keep conversion/business logic in reusable modules, not in the shell entrypoint + - [x] Add CLI regression tests for ZIP file input. + - `.mxl -> musicxml` + - `.mscz -> musicxml` + - representative invalid ZIP / missing entry failure cases if practical + - [x] Add CLI regression tests for ZIP file output. + - `musicxml -> .mxl` + - `musicxml -> .mscz` + - verify archive contents, not only file extension + - [ ] Add bounded roundtrip checks where they provide signal without making the suite too heavy. + - `musicxml -> .mxl -> musicxml` + - `musicxml -> .mscz -> musicxml` + - [x] Align current-facing docs after behavior lands. + - `docs/spec/CLI_STEP1.md` + - `docs/DEVELOPMENT.md` + - `docs/future/CLI_ROADMAP.md` + - `README.md` + +- [x] Expand CLI tests together with each new conversion pair. - Cover file input, `stdin`, `--out`, and representative failure cases. - Keep `stdout` for payload and `stderr` for diagnostics only. @@ -61,12 +112,23 @@ - `typecheck` and `build:dist` are relatively small, but `test:build:full` dominates total time - `tests/unit/playback-flow.spec.ts` currently shows a 5-second timeout failure in the full path - heavy suites currently include `playback-flow`, `lilypond-io`, and `midi-roundtrip-golden` + - `npm run test:all` also exposed a timeout in a heavy `musescore-io` roundtrip case under full-suite load - Next work: - profile `test:build:full` more deliberately and identify the longest suites/tests - decide whether more suites should move between `test:build`, `test:slow`, and `test:build:full` - investigate whether the `playback-flow` timeout is an actual regression, a flaky test, or a timeout-budget issue - consider Vitest worker/timeout settings only after the heavy-suite split is reasonably settled +- [ ] Re-evaluate heavy `musescore-io` roundtrip tests for full-suite runtime stability. + - Current observation: + - `tests/unit/musescore-io.spec.ts` roundtrip case `keeps sample7 measure 3-4 pitch spelling and accidentals on roundtrip` passed in isolation at about 6.6s but timed out at 10s during `npm run test:all` + - the neighboring `sample7` roundtrip case also takes about 6.6s in isolation + - recent CLI ZIP coverage increases total suite load, which may make marginal `musescore-io` tests fail under parallel contention + - Next work: + - first check whether the assertion can be narrowed so the test keeps its signal with less end-to-end work + - consider moving the heaviest `sample7` roundtrip cases to a slower lane if they remain expensive + - only raise per-test timeout after checking whether the case can be made cheaper or better isolated + ## ABC - [ ] Refactor `src/ts/abc-io.ts` before continuing larger ABC layout expansion. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 548f2ee..89dc4ec 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -56,7 +56,11 @@ Input/output contract: - omitted `--out` writes to `stdout` - text conversions use text input/output - MIDI input/output uses binary input/output -- current MuseScore CLI scope is `.mscx`-style text, not compressed `.mscz` +- for file input, `--from musicxml` accepts `.musicxml`, `.xml`, and `.mxl` +- for file input, `--from musescore` accepts `.mscx` and `.mscz` +- for file output, `--to musicxml` writes `.mxl` when `--out` ends with `.mxl` +- for file output, `--to musescore` writes `.mscz` when `--out` ends with `.mscz` +- `stdin` / `stdout` remain text-only for `musicxml` and `musescore` Examples: @@ -68,6 +72,9 @@ Examples: - `npm run cli -- convert --from musicxml --to midi --in score.musicxml --out score.mid` - `npm run cli -- convert --from musescore --to musicxml --in score.mscx --out score.musicxml` - `npm run cli -- convert --from musicxml --to musescore --in score.musicxml --out score.mscx` +- `npm run cli -- convert --from musicxml --to abc --in score.mxl --out score.abc` +- `npm run cli -- convert --from musescore --to musicxml --in score.mscz --out score.mxl` +- `npm run cli -- convert --from musicxml --to musescore --in score.musicxml --out score.mscz` - `npm run cli -- render svg --in score.musicxml --out score.svg` - `cat score.abc | npm run cli -- convert --from abc --to musicxml` diff --git a/docs/future/CLI_ROADMAP.md b/docs/future/CLI_ROADMAP.md index dd3cc10..d038939 100644 --- a/docs/future/CLI_ROADMAP.md +++ b/docs/future/CLI_ROADMAP.md @@ -4,7 +4,7 @@ - Step 1 first cut exists. - Initial Step 2 MIDI pairs now exist as a first cut. -- Initial Step 3 MuseScore text pairs now exist as a first cut. +- Initial Step 3 MuseScore pairs now exist as a first cut, including `.mscz` / `.mxl` file I/O support. - Initial `render svg` support now exists as a first cut. - This file tracks likely next-step expansion only. - This is a future note, not a current normative contract. @@ -58,7 +58,7 @@ Implemented first-cut Step 3 additions: Rationale: -- current CLI MuseScore scope is `.mscx`-style text; compressed `.mscz` handling is still outside the CLI contract +- CLI file I/O now accepts compressed `.mxl` / `.mscz` at the path boundary while keeping `stdin` / `stdout` text-only - `svg` is better modeled as render output, not as the same class of interchange export as `abc` / `musicxml` / `midi` ## Facade Growth Path diff --git a/docs/spec/CLI_STEP1.md b/docs/spec/CLI_STEP1.md index 8122bf1..f4b5e9e 100644 --- a/docs/spec/CLI_STEP1.md +++ b/docs/spec/CLI_STEP1.md @@ -110,11 +110,15 @@ Behavior: - `--in` specifies an input file path - if `--in` is omitted, the CLI MUST read from `stdin` - if neither file input nor `stdin` content is available, the CLI MUST fail clearly +- for file input only, `musicxml` MAY read `.musicxml`, `.xml`, or `.mxl` +- for file input only, `musescore` MAY read `.mscx` or `.mscz` ### Output Rule - `--out` specifies an output file path - if `--out` is omitted, the main result MUST be written to `stdout` +- for file output only, `--to musicxml` MAY write compressed `.mxl` when the output path ends with `.mxl` +- for file output only, `--to musescore` MAY write compressed `.mscz` when the output path ends with `.mscz` ### Help Rule @@ -189,6 +193,7 @@ Options: - main conversion result MUST go to `stdout` - warnings, diagnostics, and summary text SHOULD go to `stderr` - binary output is out of Step 1 scope +- compressed `.mxl` / `.mscz` support is limited to file-path I/O; `stdin` / `stdout` remain text-only for `musicxml` and `musescore` ## Error Contract diff --git a/index.html b/index.html index 89a808b..01188b5 100644 --- a/index.html +++ b/index.html @@ -125,7 +125,7 @@

mikuscore

-

Updated: 2026-04-15

+

Updated: 2026-04-16

English

diff --git a/mikuscore.html b/mikuscore.html index b88e8f8..bc67735 100644 --- a/mikuscore.html +++ b/mikuscore.html @@ -17937,7 +17937,22 @@

Playback Settings

* SPDX-License-Identifier: Apache-2.0 */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.extractZipEntryBytesByPath = exports.listZipRootEntryPathsByExtensions = exports.extractTextFromZipByExtensions = exports.extractMusicXmlTextFromMxl = void 0; +exports.listZipRootEntryPathsByExtensions = exports.extractZipEntryBytesByPath = exports.extractTextFromZipByExtensions = exports.extractMusicXmlTextFromMxl = void 0; +var zip_io_1 = require("./zip-io"); +Object.defineProperty(exports, "extractMusicXmlTextFromMxl", { enumerable: true, get: function () { return zip_io_1.extractMusicXmlTextFromMxl; } }); +Object.defineProperty(exports, "extractTextFromZipByExtensions", { enumerable: true, get: function () { return zip_io_1.extractTextFromZipByExtensions; } }); +Object.defineProperty(exports, "extractZipEntryBytesByPath", { enumerable: true, get: function () { return zip_io_1.extractZipEntryBytesByPath; } }); +Object.defineProperty(exports, "listZipRootEntryPathsByExtensions", { enumerable: true, get: function () { return zip_io_1.listZipRootEntryPathsByExtensions; } }); + + }, + "src/ts/zip-io.js": function (require, module, exports) { +"use strict"; +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.extractZipEntryBytesByPath = exports.listZipRootEntryPathsByExtensions = exports.extractTextFromZipByExtensions = exports.extractMusicXmlTextFromMxl = exports.makeMsczBytes = exports.makeMxlBytes = exports.makeZipBytes = exports.bytesToArrayBuffer = exports.formatXmlWithTwoSpaceIndent = void 0; const ZIP_EOCD_SIG = 0x06054b50; const ZIP_CDFH_SIG = 0x02014b50; const ZIP_LFH_SIG = 0x04034b50; @@ -17962,7 +17977,6 @@

Playback Settings

return out; }; const findEndOfCentralDirectoryOffset = (bytes) => { - // EOCD is within the last 65,557 bytes by ZIP spec. const minOffset = Math.max(0, bytes.length - 65557); for (let offset = bytes.length - 22; offset >= minOffset; offset -= 1) { if (readU32(bytes, offset) === ZIP_EOCD_SIG) @@ -18026,11 +18040,15 @@

Playback Settings

const inflateDeflateRaw = async (compressed) => { const DS = globalThis.DecompressionStream; if (!DS) { - throw new Error("DecompressionStream is not available in this browser."); + throw new Error("DecompressionStream is not available in this runtime."); } const copied = new Uint8Array(compressed.length); copied.set(compressed); - const stream = new Blob([copied.buffer]).stream().pipeThrough(new DS("deflate-raw")); + const source = new Response(copied).body; + if (!source) { + throw new Error("DecompressionStream source body is not available in this runtime."); + } + const stream = source.pipeThrough(new DS("deflate-raw")); const arrayBuffer = await new Response(stream).arrayBuffer(); return new Uint8Array(arrayBuffer); }; @@ -18041,9 +18059,6 @@

Playback Settings

} if (entry.compressionMethod === 8) { const inflated = await inflateDeflateRaw(compressed); - if (entry.uncompressedSize > 0 && inflated.length !== entry.uncompressedSize) { - // Keep going: some archives are inconsistent here, but data is often still valid. - } return inflated; } throw new Error(`Unsupported ZIP compression method: ${entry.compressionMethod}.`); @@ -18097,6 +18112,197 @@

Playback Settings

const fullPath = (_b = (_a = rootFileNode === null || rootFileNode === void 0 ? void 0 : rootFileNode.getAttribute("full-path")) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ""; return fullPath || null; }; +const crc32Table = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + } + table[n] = c >>> 0; + } + return table; +})(); +const crc32 = (bytes) => { + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i += 1) { + crc = crc32Table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +}; +const writeU16 = (target, offset, value) => { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; +}; +const writeU32 = (target, offset, value) => { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; + target[offset + 2] = (value >>> 16) & 0xff; + target[offset + 3] = (value >>> 24) & 0xff; +}; +const toDosDateTime = (date) => { + const year = Math.max(1980, Math.min(2107, date.getFullYear())); + const month = Math.max(1, Math.min(12, date.getMonth() + 1)); + const day = Math.max(1, Math.min(31, date.getDate())); + const hours = Math.max(0, Math.min(23, date.getHours())); + const minutes = Math.max(0, Math.min(59, date.getMinutes())); + const seconds = Math.max(0, Math.min(59, date.getSeconds())); + const dosTime = ((hours & 0x1f) << 11) | ((minutes & 0x3f) << 5) | ((Math.floor(seconds / 2)) & 0x1f); + const dosDate = (((year - 1980) & 0x7f) << 9) | ((month & 0x0f) << 5) | (day & 0x1f); + return { dosTime, dosDate }; +}; +const compressDeflateRaw = async (input) => { + const CS = globalThis.CompressionStream; + if (!CS) + return null; + try { + const source = new Uint8Array(input.length); + source.set(input); + const body = new Response(source).body; + if (!body) + return null; + const stream = body.pipeThrough(new CS("deflate-raw")); + const compressedBuffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(compressedBuffer); + } + catch (_a) { + return null; + } +}; +const formatXmlWithTwoSpaceIndent = (xml) => { + const compact = String(xml || "").replace(/>\s+<").trim(); + const split = compact.replace(/(>)(<)(\/*)/g, "$1\n$2$3").split("\n"); + let indentLevel = 0; + const lines = []; + for (const rawToken of split) { + const token = rawToken.trim(); + if (!token) + continue; + if (/^<\//.test(token)) + indentLevel = Math.max(0, indentLevel - 1); + lines.push(`${" ".repeat(indentLevel)}${token}`); + const isOpening = /^<[^!?/][^>]*>$/.test(token); + const isSelfClosing = /\/>$/.test(token); + if (isOpening && !isSelfClosing) + indentLevel += 1; + } + return lines.join("\n"); +}; +exports.formatXmlWithTwoSpaceIndent = formatXmlWithTwoSpaceIndent; +const bytesToArrayBuffer = (bytes) => { + const out = new ArrayBuffer(bytes.byteLength); + new Uint8Array(out).set(bytes); + return out; +}; +exports.bytesToArrayBuffer = bytesToArrayBuffer; +const makeZipBytes = async (entries, preferCompression) => { + const encoder = new TextEncoder(); + const localChunks = []; + const centralChunks = []; + let localOffset = 0; + const nowDos = toDosDateTime(new Date()); + const encodedEntries = []; + for (const entry of entries) { + const pathBytes = encoder.encode(entry.path.replace(/\\/g, "/").replace(/^\/+/, "")); + const uncompressed = entry.bytes; + let data = uncompressed; + let method = 0; + if (preferCompression) { + const compressed = await compressDeflateRaw(uncompressed); + if (compressed && compressed.length < uncompressed.length) { + data = compressed; + method = 8; + } + } + encodedEntries.push({ + pathBytes, + data, + crc: crc32(uncompressed), + method, + compressedSize: data.length, + uncompressedSize: uncompressed.length, + }); + } + for (const entry of encodedEntries) { + const { pathBytes, data, crc, method, compressedSize, uncompressedSize } = entry; + const localHeader = new Uint8Array(30 + pathBytes.length); + writeU32(localHeader, 0, 0x04034b50); + writeU16(localHeader, 4, 20); + writeU16(localHeader, 6, 0x0800); + writeU16(localHeader, 8, method); + writeU16(localHeader, 10, nowDos.dosTime); + writeU16(localHeader, 12, nowDos.dosDate); + writeU32(localHeader, 14, crc); + writeU32(localHeader, 18, compressedSize); + writeU32(localHeader, 22, uncompressedSize); + writeU16(localHeader, 26, pathBytes.length); + writeU16(localHeader, 28, 0); + localHeader.set(pathBytes, 30); + localChunks.push(localHeader, data); + const centralHeader = new Uint8Array(46 + pathBytes.length); + writeU32(centralHeader, 0, 0x02014b50); + writeU16(centralHeader, 4, 20); + writeU16(centralHeader, 6, 20); + writeU16(centralHeader, 8, 0x0800); + writeU16(centralHeader, 10, method); + writeU16(centralHeader, 12, nowDos.dosTime); + writeU16(centralHeader, 14, nowDos.dosDate); + writeU32(centralHeader, 16, crc); + writeU32(centralHeader, 20, compressedSize); + writeU32(centralHeader, 24, uncompressedSize); + writeU16(centralHeader, 28, pathBytes.length); + writeU16(centralHeader, 30, 0); + writeU16(centralHeader, 32, 0); + writeU16(centralHeader, 34, 0); + writeU16(centralHeader, 36, 0); + writeU32(centralHeader, 38, 0); + writeU32(centralHeader, 42, localOffset); + centralHeader.set(pathBytes, 46); + centralChunks.push(centralHeader); + localOffset += localHeader.length + compressedSize; + } + const localSize = localChunks.reduce((sum, b) => sum + b.length, 0); + const centralSize = centralChunks.reduce((sum, b) => sum + b.length, 0); + const eocd = new Uint8Array(22); + writeU32(eocd, 0, 0x06054b50); + writeU16(eocd, 4, 0); + writeU16(eocd, 6, 0); + writeU16(eocd, 8, entries.length); + writeU16(eocd, 10, entries.length); + writeU32(eocd, 12, centralSize); + writeU32(eocd, 16, localSize); + writeU16(eocd, 20, 0); + const out = new Uint8Array(localSize + centralSize + eocd.length); + let cursor = 0; + for (const chunk of localChunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + for (const chunk of centralChunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + out.set(eocd, cursor); + return out; +}; +exports.makeZipBytes = makeZipBytes; +const makeMxlBytes = async (formattedXml) => { + const encoder = new TextEncoder(); + const containerXml = `` + + `` + + `` + + ``; + return (0, exports.makeZipBytes)([ + { path: "META-INF/container.xml", bytes: encoder.encode(containerXml) }, + { path: "score.musicxml", bytes: encoder.encode(formattedXml) }, + ], true); +}; +exports.makeMxlBytes = makeMxlBytes; +const makeMsczBytes = async (mscxText) => { + const encoder = new TextEncoder(); + return (0, exports.makeZipBytes)([{ path: "score.mscx", bytes: encoder.encode(mscxText) }], true); +}; +exports.makeMsczBytes = makeMsczBytes; const extractMusicXmlTextFromMxl = async (archiveBuffer) => { const archiveBytes = new Uint8Array(archiveBuffer); const entries = readZipEntries(archiveBytes); @@ -18640,6 +18846,7 @@

Playback Settings

const midi_io_1 = require("./midi-io"); const midi_musescore_io_1 = require("./midi-musescore-io"); const musicxml_io_1 = require("./musicxml-io"); +const zip_io_1 = require("./zip-io"); const pad2 = (value) => String(value).padStart(2, "0"); const buildFileTimestamp = () => { const now = new Date(); @@ -18651,25 +18858,6 @@

Playback Settings

pad2(now.getMinutes()), ].join(""); }; -const prettyPrintXmlWithTwoSpaceIndent = (xml) => { - const compact = String(xml || "").replace(/>\s+<").trim(); - const split = compact.replace(/(>)(<)(\/*)/g, "$1\n$2$3").split("\n"); - let indentLevel = 0; - const lines = []; - for (const rawToken of split) { - const token = rawToken.trim(); - if (!token) - continue; - if (/^<\//.test(token)) - indentLevel = Math.max(0, indentLevel - 1); - lines.push(`${" ".repeat(indentLevel)}${token}`); - const isOpening = /^<[^!?/][^>]*>$/.test(token); - const isSelfClosing = /\/>$/.test(token); - if (isOpening && !isSelfClosing) - indentLevel += 1; - } - return lines.join("\n"); -}; const triggerFileDownload = (payload) => { const url = URL.createObjectURL(payload.blob); const a = document.createElement("a"); @@ -18679,178 +18867,14 @@

Playback Settings

URL.revokeObjectURL(url); }; exports.triggerFileDownload = triggerFileDownload; -const crc32Table = (() => { - const table = new Uint32Array(256); - for (let n = 0; n < 256; n += 1) { - let c = n; - for (let k = 0; k < 8; k += 1) { - c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); - } - table[n] = c >>> 0; - } - return table; -})(); -const crc32 = (bytes) => { - let crc = 0xffffffff; - for (let i = 0; i < bytes.length; i += 1) { - crc = crc32Table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); - } - return (crc ^ 0xffffffff) >>> 0; -}; -const writeU16 = (target, offset, value) => { - target[offset] = value & 0xff; - target[offset + 1] = (value >>> 8) & 0xff; -}; -const writeU32 = (target, offset, value) => { - target[offset] = value & 0xff; - target[offset + 1] = (value >>> 8) & 0xff; - target[offset + 2] = (value >>> 16) & 0xff; - target[offset + 3] = (value >>> 24) & 0xff; -}; -const toDosDateTime = (date) => { - const year = Math.max(1980, Math.min(2107, date.getFullYear())); - const month = Math.max(1, Math.min(12, date.getMonth() + 1)); - const day = Math.max(1, Math.min(31, date.getDate())); - const hours = Math.max(0, Math.min(23, date.getHours())); - const minutes = Math.max(0, Math.min(59, date.getMinutes())); - const seconds = Math.max(0, Math.min(59, date.getSeconds())); - const dosTime = ((hours & 0x1f) << 11) | ((minutes & 0x3f) << 5) | ((Math.floor(seconds / 2)) & 0x1f); - const dosDate = (((year - 1980) & 0x7f) << 9) | ((month & 0x0f) << 5) | (day & 0x1f); - return { dosTime, dosDate }; -}; -const compressDeflateRaw = async (input) => { - const CS = globalThis.CompressionStream; - if (!CS) - return null; - try { - const source = new Uint8Array(input.length); - source.set(input); - const stream = new Blob([bytesToArrayBuffer(source)]).stream().pipeThrough(new CS("deflate-raw")); - const compressedBuffer = await new Response(stream).arrayBuffer(); - return new Uint8Array(compressedBuffer); - } - catch (_a) { - return null; - } -}; -const makeZipBytes = async (entries, preferCompression) => { - const encoder = new TextEncoder(); - const localChunks = []; - const centralChunks = []; - let localOffset = 0; - const nowDos = toDosDateTime(new Date()); - const encodedEntries = []; - for (const entry of entries) { - const pathBytes = encoder.encode(entry.path.replace(/\\/g, "/").replace(/^\/+/, "")); - const uncompressed = entry.bytes; - let data = uncompressed; - let method = 0; - if (preferCompression) { - const compressed = await compressDeflateRaw(uncompressed); - if (compressed && compressed.length < uncompressed.length) { - data = compressed; - method = 8; - } - } - encodedEntries.push({ - pathBytes, - data, - crc: crc32(uncompressed), - method, - compressedSize: data.length, - uncompressedSize: uncompressed.length, - }); - } - for (const entry of encodedEntries) { - const { pathBytes, data, crc, method, compressedSize, uncompressedSize } = entry; - const localHeader = new Uint8Array(30 + pathBytes.length); - writeU32(localHeader, 0, 0x04034b50); - writeU16(localHeader, 4, 20); - writeU16(localHeader, 6, 0x0800); - writeU16(localHeader, 8, method); - writeU16(localHeader, 10, nowDos.dosTime); - writeU16(localHeader, 12, nowDos.dosDate); - writeU32(localHeader, 14, crc); - writeU32(localHeader, 18, compressedSize); - writeU32(localHeader, 22, uncompressedSize); - writeU16(localHeader, 26, pathBytes.length); - writeU16(localHeader, 28, 0); - localHeader.set(pathBytes, 30); - localChunks.push(localHeader, data); - const centralHeader = new Uint8Array(46 + pathBytes.length); - writeU32(centralHeader, 0, 0x02014b50); - writeU16(centralHeader, 4, 20); - writeU16(centralHeader, 6, 20); - writeU16(centralHeader, 8, 0x0800); - writeU16(centralHeader, 10, method); - writeU16(centralHeader, 12, nowDos.dosTime); - writeU16(centralHeader, 14, nowDos.dosDate); - writeU32(centralHeader, 16, crc); - writeU32(centralHeader, 20, compressedSize); - writeU32(centralHeader, 24, uncompressedSize); - writeU16(centralHeader, 28, pathBytes.length); - writeU16(centralHeader, 30, 0); - writeU16(centralHeader, 32, 0); - writeU16(centralHeader, 34, 0); - writeU16(centralHeader, 36, 0); - writeU32(centralHeader, 38, 0); - writeU32(centralHeader, 42, localOffset); - centralHeader.set(pathBytes, 46); - centralChunks.push(centralHeader); - localOffset += localHeader.length + compressedSize; - } - const localSize = localChunks.reduce((sum, b) => sum + b.length, 0); - const centralSize = centralChunks.reduce((sum, b) => sum + b.length, 0); - const eocd = new Uint8Array(22); - writeU32(eocd, 0, 0x06054b50); - writeU16(eocd, 4, 0); - writeU16(eocd, 6, 0); - writeU16(eocd, 8, entries.length); - writeU16(eocd, 10, entries.length); - writeU32(eocd, 12, centralSize); - writeU32(eocd, 16, localSize); - writeU16(eocd, 20, 0); - const out = new Uint8Array(localSize + centralSize + eocd.length); - let cursor = 0; - for (const chunk of localChunks) { - out.set(chunk, cursor); - cursor += chunk.length; - } - for (const chunk of centralChunks) { - out.set(chunk, cursor); - cursor += chunk.length; - } - out.set(eocd, cursor); - return out; -}; -const makeMxlBytes = async (formattedXml) => { - const encoder = new TextEncoder(); - const containerXml = `` + - `` + - `` + - ``; - return makeZipBytes([ - { path: "META-INF/container.xml", bytes: encoder.encode(containerXml) }, - { path: "score.musicxml", bytes: encoder.encode(formattedXml) }, - ], true); -}; -const makeMsczBytes = async (mscxText) => { - const encoder = new TextEncoder(); - return makeZipBytes([{ path: "score.mscx", bytes: encoder.encode(mscxText) }], true); -}; -const bytesToArrayBuffer = (bytes) => { - const out = new ArrayBuffer(bytes.byteLength); - new Uint8Array(out).set(bytes); - return out; -}; const createMusicXmlDownloadPayload = async (xmlText, options = {}) => { const ts = buildFileTimestamp(); const formattedXml = (0, musicxml_io_1.prettyPrintMusicXmlText)(xmlText); if (options.compressed === true) { - const mxlBytes = await makeMxlBytes(formattedXml); + const mxlBytes = await (0, zip_io_1.makeMxlBytes)(formattedXml); return { fileName: `mikuscore-${ts}.mxl`, - blob: new Blob([bytesToArrayBuffer(mxlBytes)], { type: "application/vnd.recordare.musicxml" }), + blob: new Blob([(0, zip_io_1.bytesToArrayBuffer)(mxlBytes)], { type: "application/vnd.recordare.musicxml" }), }; } const extension = options.useXmlExtension === true ? "xml" : "musicxml"; @@ -18878,7 +18902,7 @@

Playback Settings

exports.createJsonDownloadPayload = createJsonDownloadPayload; const createVsqxDownloadPayload = (vsqxText) => { const ts = buildFileTimestamp(); - const formattedVsqx = prettyPrintXmlWithTwoSpaceIndent(vsqxText); + const formattedVsqx = (0, zip_io_1.formatXmlWithTwoSpaceIndent)(vsqxText); return { fileName: `mikuscore-${ts}.vsqx`, blob: new Blob([formattedVsqx], { type: "application/xml;charset=utf-8" }), @@ -19011,13 +19035,13 @@

Playback Settings

catch (_a) { return null; } - const formattedMscx = prettyPrintXmlWithTwoSpaceIndent(mscxText); + const formattedMscx = (0, zip_io_1.formatXmlWithTwoSpaceIndent)(mscxText); const ts = buildFileTimestamp(); if (options.compressed === true) { - const msczBytes = await makeMsczBytes(formattedMscx); + const msczBytes = await (0, zip_io_1.makeMsczBytes)(formattedMscx); return { fileName: `mikuscore-${ts}.mscz`, - blob: new Blob([bytesToArrayBuffer(msczBytes)], { type: "application/zip" }), + blob: new Blob([(0, zip_io_1.bytesToArrayBuffer)(msczBytes)], { type: "application/zip" }), }; } return { @@ -19037,10 +19061,10 @@

Playback Settings

const bytes = new Uint8Array(await entry.blob.arrayBuffer()); zipEntries.push({ path: fileName, bytes }); } - const zipBytes = await makeZipBytes(zipEntries, options.compressed !== false); + const zipBytes = await (0, zip_io_1.makeZipBytes)(zipEntries, options.compressed !== false); return { fileName: `${safeBase}-${ts}.zip`, - blob: new Blob([bytesToArrayBuffer(zipBytes)], { type: "application/zip" }), + blob: new Blob([(0, zip_io_1.bytesToArrayBuffer)(zipBytes)], { type: "application/zip" }), }; }; exports.createZipBundleDownloadPayload = createZipBundleDownloadPayload; diff --git a/scripts/mikuscore-cli.mjs b/scripts/mikuscore-cli.mjs index 6bcb3ed..1ee35e3 100644 --- a/scripts/mikuscore-cli.mjs +++ b/scripts/mikuscore-cli.mjs @@ -53,10 +53,12 @@ Supported pairs: Input: --in Read source text from file stdin Used when --in is omitted + file paths musicxml accepts .musicxml / .xml / .mxl; musescore accepts .mscx / .mscz Output: --out Write converted text to file stdout Used when --out is omitted + file paths --to musicxml writes .mxl when --out ends with .mxl; --to musescore writes .mscz when --out ends with .mscz Options: --from Source format @@ -185,11 +187,16 @@ async function runCommand(command, options, api) { if (!result.ok) { throw new CliCommandFailure(result, "ABC to MusicXML conversion failed."); } - return result; + return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; } if (from === "musicxml" && to === "abc") { - const inputText = await readTextInput(options.in); + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + const inputText = decoded.output; const result = api.abc.exportFromMusicXml(inputText); if (!result.ok) { throw new CliCommandFailure(result, "MusicXML to ABC conversion failed."); @@ -203,11 +210,16 @@ async function runCommand(command, options, api) { if (!result.ok) { throw new CliCommandFailure(result, "MIDI to MusicXML conversion failed."); } - return result; + return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; } if (from === "musicxml" && to === "midi") { - const inputText = await readTextInput(options.in); + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + const inputText = decoded.output; const result = api.midi.exportFromMusicXml(inputText); if (!result.ok) { throw new CliCommandFailure(result, "MusicXML to MIDI conversion failed."); @@ -216,28 +228,42 @@ async function runCommand(command, options, api) { } if (from === "musescore" && to === "musicxml") { - const inputText = await readTextInput(options.in); - const result = api.musescore.importToMusicXml(inputText); + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musescore.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MuseScore input."); + } + const result = api.musescore.importToMusicXml(decoded.output); if (!result.ok) { throw new CliCommandFailure(result, "MuseScore to MusicXML conversion failed."); } - return result; + return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; } if (from === "musicxml" && to === "musescore") { - const inputText = await readTextInput(options.in); + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + const inputText = decoded.output; const result = api.musescore.exportFromMusicXml(inputText); if (!result.ok) { throw new CliCommandFailure(result, "MusicXML to MuseScore conversion failed."); } - return result; + return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; } throw new Error(`Unsupported conversion pair: --from ${from} --to ${to}`); } if (isCommand(command, ["render", "svg"])) { - const inputText = await readTextInput(options.in); + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + const inputText = decoded.output; const result = await api.render.svgFromMusicXml(inputText); if (!result.ok) { throw new CliCommandFailure(result, "SVG render failed."); @@ -248,6 +274,17 @@ async function runCommand(command, options, api) { throw new Error(`Unsupported command: ${command.join(" ")}`); } +async function encodeOutputForTarget(result, outPath, api, to) { + if (!result.ok) return result; + if (to === "musicxml" && typeof result.output === "string") { + return api.fileIO.musicxml.encodeOutput(result.output, outPath); + } + if (to === "musescore" && typeof result.output === "string") { + return api.fileIO.musescore.encodeOutput(result.output, outPath); + } + return result; +} + async function readTextInput(inputPath) { const bytes = await readBinaryInput(inputPath); return Buffer.from(bytes).toString("utf8"); diff --git a/src/js/main.js b/src/js/main.js index 7cf118a..d229ba8 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -9887,7 +9887,22 @@ Object.defineProperty(exports, "__esModule", { value: true }); * SPDX-License-Identifier: Apache-2.0 */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.extractZipEntryBytesByPath = exports.listZipRootEntryPathsByExtensions = exports.extractTextFromZipByExtensions = exports.extractMusicXmlTextFromMxl = void 0; +exports.listZipRootEntryPathsByExtensions = exports.extractZipEntryBytesByPath = exports.extractTextFromZipByExtensions = exports.extractMusicXmlTextFromMxl = void 0; +var zip_io_1 = require("./zip-io"); +Object.defineProperty(exports, "extractMusicXmlTextFromMxl", { enumerable: true, get: function () { return zip_io_1.extractMusicXmlTextFromMxl; } }); +Object.defineProperty(exports, "extractTextFromZipByExtensions", { enumerable: true, get: function () { return zip_io_1.extractTextFromZipByExtensions; } }); +Object.defineProperty(exports, "extractZipEntryBytesByPath", { enumerable: true, get: function () { return zip_io_1.extractZipEntryBytesByPath; } }); +Object.defineProperty(exports, "listZipRootEntryPathsByExtensions", { enumerable: true, get: function () { return zip_io_1.listZipRootEntryPathsByExtensions; } }); + + }, + "src/ts/zip-io.js": function (require, module, exports) { +"use strict"; +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.extractZipEntryBytesByPath = exports.listZipRootEntryPathsByExtensions = exports.extractTextFromZipByExtensions = exports.extractMusicXmlTextFromMxl = exports.makeMsczBytes = exports.makeMxlBytes = exports.makeZipBytes = exports.bytesToArrayBuffer = exports.formatXmlWithTwoSpaceIndent = void 0; const ZIP_EOCD_SIG = 0x06054b50; const ZIP_CDFH_SIG = 0x02014b50; const ZIP_LFH_SIG = 0x04034b50; @@ -9912,7 +9927,6 @@ const decodeZipFileName = (bytes, utf8Flag) => { return out; }; const findEndOfCentralDirectoryOffset = (bytes) => { - // EOCD is within the last 65,557 bytes by ZIP spec. const minOffset = Math.max(0, bytes.length - 65557); for (let offset = bytes.length - 22; offset >= minOffset; offset -= 1) { if (readU32(bytes, offset) === ZIP_EOCD_SIG) @@ -9976,11 +9990,15 @@ const readZipEntries = (bytes) => { const inflateDeflateRaw = async (compressed) => { const DS = globalThis.DecompressionStream; if (!DS) { - throw new Error("DecompressionStream is not available in this browser."); + throw new Error("DecompressionStream is not available in this runtime."); } const copied = new Uint8Array(compressed.length); copied.set(compressed); - const stream = new Blob([copied.buffer]).stream().pipeThrough(new DS("deflate-raw")); + const source = new Response(copied).body; + if (!source) { + throw new Error("DecompressionStream source body is not available in this runtime."); + } + const stream = source.pipeThrough(new DS("deflate-raw")); const arrayBuffer = await new Response(stream).arrayBuffer(); return new Uint8Array(arrayBuffer); }; @@ -9991,9 +10009,6 @@ const extractEntryBytes = async (archiveBytes, entry) => { } if (entry.compressionMethod === 8) { const inflated = await inflateDeflateRaw(compressed); - if (entry.uncompressedSize > 0 && inflated.length !== entry.uncompressedSize) { - // Keep going: some archives are inconsistent here, but data is often still valid. - } return inflated; } throw new Error(`Unsupported ZIP compression method: ${entry.compressionMethod}.`); @@ -10047,6 +10062,197 @@ const parseContainerRootFilePath = (containerXmlText) => { const fullPath = (_b = (_a = rootFileNode === null || rootFileNode === void 0 ? void 0 : rootFileNode.getAttribute("full-path")) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ""; return fullPath || null; }; +const crc32Table = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + } + table[n] = c >>> 0; + } + return table; +})(); +const crc32 = (bytes) => { + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i += 1) { + crc = crc32Table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +}; +const writeU16 = (target, offset, value) => { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; +}; +const writeU32 = (target, offset, value) => { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; + target[offset + 2] = (value >>> 16) & 0xff; + target[offset + 3] = (value >>> 24) & 0xff; +}; +const toDosDateTime = (date) => { + const year = Math.max(1980, Math.min(2107, date.getFullYear())); + const month = Math.max(1, Math.min(12, date.getMonth() + 1)); + const day = Math.max(1, Math.min(31, date.getDate())); + const hours = Math.max(0, Math.min(23, date.getHours())); + const minutes = Math.max(0, Math.min(59, date.getMinutes())); + const seconds = Math.max(0, Math.min(59, date.getSeconds())); + const dosTime = ((hours & 0x1f) << 11) | ((minutes & 0x3f) << 5) | ((Math.floor(seconds / 2)) & 0x1f); + const dosDate = (((year - 1980) & 0x7f) << 9) | ((month & 0x0f) << 5) | (day & 0x1f); + return { dosTime, dosDate }; +}; +const compressDeflateRaw = async (input) => { + const CS = globalThis.CompressionStream; + if (!CS) + return null; + try { + const source = new Uint8Array(input.length); + source.set(input); + const body = new Response(source).body; + if (!body) + return null; + const stream = body.pipeThrough(new CS("deflate-raw")); + const compressedBuffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(compressedBuffer); + } + catch (_a) { + return null; + } +}; +const formatXmlWithTwoSpaceIndent = (xml) => { + const compact = String(xml || "").replace(/>\s+<").trim(); + const split = compact.replace(/(>)(<)(\/*)/g, "$1\n$2$3").split("\n"); + let indentLevel = 0; + const lines = []; + for (const rawToken of split) { + const token = rawToken.trim(); + if (!token) + continue; + if (/^<\//.test(token)) + indentLevel = Math.max(0, indentLevel - 1); + lines.push(`${" ".repeat(indentLevel)}${token}`); + const isOpening = /^<[^!?/][^>]*>$/.test(token); + const isSelfClosing = /\/>$/.test(token); + if (isOpening && !isSelfClosing) + indentLevel += 1; + } + return lines.join("\n"); +}; +exports.formatXmlWithTwoSpaceIndent = formatXmlWithTwoSpaceIndent; +const bytesToArrayBuffer = (bytes) => { + const out = new ArrayBuffer(bytes.byteLength); + new Uint8Array(out).set(bytes); + return out; +}; +exports.bytesToArrayBuffer = bytesToArrayBuffer; +const makeZipBytes = async (entries, preferCompression) => { + const encoder = new TextEncoder(); + const localChunks = []; + const centralChunks = []; + let localOffset = 0; + const nowDos = toDosDateTime(new Date()); + const encodedEntries = []; + for (const entry of entries) { + const pathBytes = encoder.encode(entry.path.replace(/\\/g, "/").replace(/^\/+/, "")); + const uncompressed = entry.bytes; + let data = uncompressed; + let method = 0; + if (preferCompression) { + const compressed = await compressDeflateRaw(uncompressed); + if (compressed && compressed.length < uncompressed.length) { + data = compressed; + method = 8; + } + } + encodedEntries.push({ + pathBytes, + data, + crc: crc32(uncompressed), + method, + compressedSize: data.length, + uncompressedSize: uncompressed.length, + }); + } + for (const entry of encodedEntries) { + const { pathBytes, data, crc, method, compressedSize, uncompressedSize } = entry; + const localHeader = new Uint8Array(30 + pathBytes.length); + writeU32(localHeader, 0, 0x04034b50); + writeU16(localHeader, 4, 20); + writeU16(localHeader, 6, 0x0800); + writeU16(localHeader, 8, method); + writeU16(localHeader, 10, nowDos.dosTime); + writeU16(localHeader, 12, nowDos.dosDate); + writeU32(localHeader, 14, crc); + writeU32(localHeader, 18, compressedSize); + writeU32(localHeader, 22, uncompressedSize); + writeU16(localHeader, 26, pathBytes.length); + writeU16(localHeader, 28, 0); + localHeader.set(pathBytes, 30); + localChunks.push(localHeader, data); + const centralHeader = new Uint8Array(46 + pathBytes.length); + writeU32(centralHeader, 0, 0x02014b50); + writeU16(centralHeader, 4, 20); + writeU16(centralHeader, 6, 20); + writeU16(centralHeader, 8, 0x0800); + writeU16(centralHeader, 10, method); + writeU16(centralHeader, 12, nowDos.dosTime); + writeU16(centralHeader, 14, nowDos.dosDate); + writeU32(centralHeader, 16, crc); + writeU32(centralHeader, 20, compressedSize); + writeU32(centralHeader, 24, uncompressedSize); + writeU16(centralHeader, 28, pathBytes.length); + writeU16(centralHeader, 30, 0); + writeU16(centralHeader, 32, 0); + writeU16(centralHeader, 34, 0); + writeU16(centralHeader, 36, 0); + writeU32(centralHeader, 38, 0); + writeU32(centralHeader, 42, localOffset); + centralHeader.set(pathBytes, 46); + centralChunks.push(centralHeader); + localOffset += localHeader.length + compressedSize; + } + const localSize = localChunks.reduce((sum, b) => sum + b.length, 0); + const centralSize = centralChunks.reduce((sum, b) => sum + b.length, 0); + const eocd = new Uint8Array(22); + writeU32(eocd, 0, 0x06054b50); + writeU16(eocd, 4, 0); + writeU16(eocd, 6, 0); + writeU16(eocd, 8, entries.length); + writeU16(eocd, 10, entries.length); + writeU32(eocd, 12, centralSize); + writeU32(eocd, 16, localSize); + writeU16(eocd, 20, 0); + const out = new Uint8Array(localSize + centralSize + eocd.length); + let cursor = 0; + for (const chunk of localChunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + for (const chunk of centralChunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + out.set(eocd, cursor); + return out; +}; +exports.makeZipBytes = makeZipBytes; +const makeMxlBytes = async (formattedXml) => { + const encoder = new TextEncoder(); + const containerXml = `` + + `` + + `` + + ``; + return (0, exports.makeZipBytes)([ + { path: "META-INF/container.xml", bytes: encoder.encode(containerXml) }, + { path: "score.musicxml", bytes: encoder.encode(formattedXml) }, + ], true); +}; +exports.makeMxlBytes = makeMxlBytes; +const makeMsczBytes = async (mscxText) => { + const encoder = new TextEncoder(); + return (0, exports.makeZipBytes)([{ path: "score.mscx", bytes: encoder.encode(mscxText) }], true); +}; +exports.makeMsczBytes = makeMsczBytes; const extractMusicXmlTextFromMxl = async (archiveBuffer) => { const archiveBytes = new Uint8Array(archiveBuffer); const entries = readZipEntries(archiveBytes); @@ -10590,6 +10796,7 @@ exports.createZipBundleDownloadPayload = exports.createMuseScoreDownloadPayload const midi_io_1 = require("./midi-io"); const midi_musescore_io_1 = require("./midi-musescore-io"); const musicxml_io_1 = require("./musicxml-io"); +const zip_io_1 = require("./zip-io"); const pad2 = (value) => String(value).padStart(2, "0"); const buildFileTimestamp = () => { const now = new Date(); @@ -10601,25 +10808,6 @@ const buildFileTimestamp = () => { pad2(now.getMinutes()), ].join(""); }; -const prettyPrintXmlWithTwoSpaceIndent = (xml) => { - const compact = String(xml || "").replace(/>\s+<").trim(); - const split = compact.replace(/(>)(<)(\/*)/g, "$1\n$2$3").split("\n"); - let indentLevel = 0; - const lines = []; - for (const rawToken of split) { - const token = rawToken.trim(); - if (!token) - continue; - if (/^<\//.test(token)) - indentLevel = Math.max(0, indentLevel - 1); - lines.push(`${" ".repeat(indentLevel)}${token}`); - const isOpening = /^<[^!?/][^>]*>$/.test(token); - const isSelfClosing = /\/>$/.test(token); - if (isOpening && !isSelfClosing) - indentLevel += 1; - } - return lines.join("\n"); -}; const triggerFileDownload = (payload) => { const url = URL.createObjectURL(payload.blob); const a = document.createElement("a"); @@ -10629,178 +10817,14 @@ const triggerFileDownload = (payload) => { URL.revokeObjectURL(url); }; exports.triggerFileDownload = triggerFileDownload; -const crc32Table = (() => { - const table = new Uint32Array(256); - for (let n = 0; n < 256; n += 1) { - let c = n; - for (let k = 0; k < 8; k += 1) { - c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); - } - table[n] = c >>> 0; - } - return table; -})(); -const crc32 = (bytes) => { - let crc = 0xffffffff; - for (let i = 0; i < bytes.length; i += 1) { - crc = crc32Table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); - } - return (crc ^ 0xffffffff) >>> 0; -}; -const writeU16 = (target, offset, value) => { - target[offset] = value & 0xff; - target[offset + 1] = (value >>> 8) & 0xff; -}; -const writeU32 = (target, offset, value) => { - target[offset] = value & 0xff; - target[offset + 1] = (value >>> 8) & 0xff; - target[offset + 2] = (value >>> 16) & 0xff; - target[offset + 3] = (value >>> 24) & 0xff; -}; -const toDosDateTime = (date) => { - const year = Math.max(1980, Math.min(2107, date.getFullYear())); - const month = Math.max(1, Math.min(12, date.getMonth() + 1)); - const day = Math.max(1, Math.min(31, date.getDate())); - const hours = Math.max(0, Math.min(23, date.getHours())); - const minutes = Math.max(0, Math.min(59, date.getMinutes())); - const seconds = Math.max(0, Math.min(59, date.getSeconds())); - const dosTime = ((hours & 0x1f) << 11) | ((minutes & 0x3f) << 5) | ((Math.floor(seconds / 2)) & 0x1f); - const dosDate = (((year - 1980) & 0x7f) << 9) | ((month & 0x0f) << 5) | (day & 0x1f); - return { dosTime, dosDate }; -}; -const compressDeflateRaw = async (input) => { - const CS = globalThis.CompressionStream; - if (!CS) - return null; - try { - const source = new Uint8Array(input.length); - source.set(input); - const stream = new Blob([bytesToArrayBuffer(source)]).stream().pipeThrough(new CS("deflate-raw")); - const compressedBuffer = await new Response(stream).arrayBuffer(); - return new Uint8Array(compressedBuffer); - } - catch (_a) { - return null; - } -}; -const makeZipBytes = async (entries, preferCompression) => { - const encoder = new TextEncoder(); - const localChunks = []; - const centralChunks = []; - let localOffset = 0; - const nowDos = toDosDateTime(new Date()); - const encodedEntries = []; - for (const entry of entries) { - const pathBytes = encoder.encode(entry.path.replace(/\\/g, "/").replace(/^\/+/, "")); - const uncompressed = entry.bytes; - let data = uncompressed; - let method = 0; - if (preferCompression) { - const compressed = await compressDeflateRaw(uncompressed); - if (compressed && compressed.length < uncompressed.length) { - data = compressed; - method = 8; - } - } - encodedEntries.push({ - pathBytes, - data, - crc: crc32(uncompressed), - method, - compressedSize: data.length, - uncompressedSize: uncompressed.length, - }); - } - for (const entry of encodedEntries) { - const { pathBytes, data, crc, method, compressedSize, uncompressedSize } = entry; - const localHeader = new Uint8Array(30 + pathBytes.length); - writeU32(localHeader, 0, 0x04034b50); - writeU16(localHeader, 4, 20); - writeU16(localHeader, 6, 0x0800); - writeU16(localHeader, 8, method); - writeU16(localHeader, 10, nowDos.dosTime); - writeU16(localHeader, 12, nowDos.dosDate); - writeU32(localHeader, 14, crc); - writeU32(localHeader, 18, compressedSize); - writeU32(localHeader, 22, uncompressedSize); - writeU16(localHeader, 26, pathBytes.length); - writeU16(localHeader, 28, 0); - localHeader.set(pathBytes, 30); - localChunks.push(localHeader, data); - const centralHeader = new Uint8Array(46 + pathBytes.length); - writeU32(centralHeader, 0, 0x02014b50); - writeU16(centralHeader, 4, 20); - writeU16(centralHeader, 6, 20); - writeU16(centralHeader, 8, 0x0800); - writeU16(centralHeader, 10, method); - writeU16(centralHeader, 12, nowDos.dosTime); - writeU16(centralHeader, 14, nowDos.dosDate); - writeU32(centralHeader, 16, crc); - writeU32(centralHeader, 20, compressedSize); - writeU32(centralHeader, 24, uncompressedSize); - writeU16(centralHeader, 28, pathBytes.length); - writeU16(centralHeader, 30, 0); - writeU16(centralHeader, 32, 0); - writeU16(centralHeader, 34, 0); - writeU16(centralHeader, 36, 0); - writeU32(centralHeader, 38, 0); - writeU32(centralHeader, 42, localOffset); - centralHeader.set(pathBytes, 46); - centralChunks.push(centralHeader); - localOffset += localHeader.length + compressedSize; - } - const localSize = localChunks.reduce((sum, b) => sum + b.length, 0); - const centralSize = centralChunks.reduce((sum, b) => sum + b.length, 0); - const eocd = new Uint8Array(22); - writeU32(eocd, 0, 0x06054b50); - writeU16(eocd, 4, 0); - writeU16(eocd, 6, 0); - writeU16(eocd, 8, entries.length); - writeU16(eocd, 10, entries.length); - writeU32(eocd, 12, centralSize); - writeU32(eocd, 16, localSize); - writeU16(eocd, 20, 0); - const out = new Uint8Array(localSize + centralSize + eocd.length); - let cursor = 0; - for (const chunk of localChunks) { - out.set(chunk, cursor); - cursor += chunk.length; - } - for (const chunk of centralChunks) { - out.set(chunk, cursor); - cursor += chunk.length; - } - out.set(eocd, cursor); - return out; -}; -const makeMxlBytes = async (formattedXml) => { - const encoder = new TextEncoder(); - const containerXml = `` + - `` + - `` + - ``; - return makeZipBytes([ - { path: "META-INF/container.xml", bytes: encoder.encode(containerXml) }, - { path: "score.musicxml", bytes: encoder.encode(formattedXml) }, - ], true); -}; -const makeMsczBytes = async (mscxText) => { - const encoder = new TextEncoder(); - return makeZipBytes([{ path: "score.mscx", bytes: encoder.encode(mscxText) }], true); -}; -const bytesToArrayBuffer = (bytes) => { - const out = new ArrayBuffer(bytes.byteLength); - new Uint8Array(out).set(bytes); - return out; -}; const createMusicXmlDownloadPayload = async (xmlText, options = {}) => { const ts = buildFileTimestamp(); const formattedXml = (0, musicxml_io_1.prettyPrintMusicXmlText)(xmlText); if (options.compressed === true) { - const mxlBytes = await makeMxlBytes(formattedXml); + const mxlBytes = await (0, zip_io_1.makeMxlBytes)(formattedXml); return { fileName: `mikuscore-${ts}.mxl`, - blob: new Blob([bytesToArrayBuffer(mxlBytes)], { type: "application/vnd.recordare.musicxml" }), + blob: new Blob([(0, zip_io_1.bytesToArrayBuffer)(mxlBytes)], { type: "application/vnd.recordare.musicxml" }), }; } const extension = options.useXmlExtension === true ? "xml" : "musicxml"; @@ -10828,7 +10852,7 @@ const createJsonDownloadPayload = (jsonText, stem = "measure-detail") => { exports.createJsonDownloadPayload = createJsonDownloadPayload; const createVsqxDownloadPayload = (vsqxText) => { const ts = buildFileTimestamp(); - const formattedVsqx = prettyPrintXmlWithTwoSpaceIndent(vsqxText); + const formattedVsqx = (0, zip_io_1.formatXmlWithTwoSpaceIndent)(vsqxText); return { fileName: `mikuscore-${ts}.vsqx`, blob: new Blob([formattedVsqx], { type: "application/xml;charset=utf-8" }), @@ -10961,13 +10985,13 @@ const createMuseScoreDownloadPayload = async (xmlText, convertMusicXmlToMuseScor catch (_a) { return null; } - const formattedMscx = prettyPrintXmlWithTwoSpaceIndent(mscxText); + const formattedMscx = (0, zip_io_1.formatXmlWithTwoSpaceIndent)(mscxText); const ts = buildFileTimestamp(); if (options.compressed === true) { - const msczBytes = await makeMsczBytes(formattedMscx); + const msczBytes = await (0, zip_io_1.makeMsczBytes)(formattedMscx); return { fileName: `mikuscore-${ts}.mscz`, - blob: new Blob([bytesToArrayBuffer(msczBytes)], { type: "application/zip" }), + blob: new Blob([(0, zip_io_1.bytesToArrayBuffer)(msczBytes)], { type: "application/zip" }), }; } return { @@ -10987,10 +11011,10 @@ const createZipBundleDownloadPayload = async (entries, options = {}) => { const bytes = new Uint8Array(await entry.blob.arrayBuffer()); zipEntries.push({ path: fileName, bytes }); } - const zipBytes = await makeZipBytes(zipEntries, options.compressed !== false); + const zipBytes = await (0, zip_io_1.makeZipBytes)(zipEntries, options.compressed !== false); return { fileName: `${safeBase}-${ts}.zip`, - blob: new Blob([bytesToArrayBuffer(zipBytes)], { type: "application/zip" }), + blob: new Blob([(0, zip_io_1.bytesToArrayBuffer)(zipBytes)], { type: "application/zip" }), }; }; exports.createZipBundleDownloadPayload = createZipBundleDownloadPayload; diff --git a/src/ts/cli-api.ts b/src/ts/cli-api.ts index 1f65cb0..a96334b 100644 --- a/src/ts/cli-api.ts +++ b/src/ts/cli-api.ts @@ -18,6 +18,14 @@ import { import { normalizeImportedMusicXmlText, parseMusicXmlDocument } from "./musicxml-io"; import { convertMuseScoreToMusicXml, exportMusicXmlDomToMuseScore } from "./musescore-io"; import { renderMusicXmlDomToSvg } from "./verovio-out"; +import { + bytesToArrayBuffer, + extractMusicXmlTextFromMxl, + extractTextFromZipByExtensions, + formatXmlWithTwoSpaceIndent, + makeMsczBytes, + makeMxlBytes, +} from "./zip-io"; export type CliResult = | { @@ -32,6 +40,81 @@ export type CliResult = diagnostics: string[]; }; +const lowerFileName = (fileName: string | undefined): string => { + return String(fileName || "").trim().toLowerCase(); +}; + +const textResult = (output: string): CliResult => ({ + ok: true, + output, + warnings: [], + diagnostics: [], +}); + +const bytesResult = (output: Uint8Array): CliResult => ({ + ok: true, + output, + warnings: [], + diagnostics: [], +}); + +const failureResult = (message: string): CliResult => ({ + ok: false, + warnings: [], + diagnostics: [message], +}); + +export const decodeCliMusicXmlInput = async (inputBytes: Uint8Array, inputPath?: string): Promise => { + const name = lowerFileName(inputPath); + try { + if (name.endsWith(".mxl")) { + return textResult(await extractMusicXmlTextFromMxl(bytesToArrayBuffer(inputBytes))); + } + return textResult(Buffer.from(inputBytes).toString("utf8")); + } catch (error) { + return failureResult(`Failed to read MusicXML input: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export const decodeCliMuseScoreInput = async (inputBytes: Uint8Array, inputPath?: string): Promise => { + const name = lowerFileName(inputPath); + try { + if (name.endsWith(".mscz")) { + return textResult(await extractTextFromZipByExtensions( + bytesToArrayBuffer(inputBytes), + [".mscx"] + )); + } + return textResult(Buffer.from(inputBytes).toString("utf8")); + } catch (error) { + return failureResult(`Failed to read MuseScore input: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export const encodeCliMusicXmlOutput = async (xmlText: string, outputPath?: string): Promise => { + const name = lowerFileName(outputPath); + try { + if (name.endsWith(".mxl")) { + return bytesResult(await makeMxlBytes(xmlText)); + } + return textResult(xmlText); + } catch (error) { + return failureResult(`Failed to encode MusicXML output: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export const encodeCliMuseScoreOutput = async (musescoreText: string, outputPath?: string): Promise => { + const name = lowerFileName(outputPath); + try { + if (name.endsWith(".mscz")) { + return bytesResult(await makeMsczBytes(formatXmlWithTwoSpaceIndent(musescoreText))); + } + return textResult(musescoreText); + } catch (error) { + return failureResult(`Failed to encode MuseScore output: ${error instanceof Error ? error.message : String(error)}`); + } +}; + export const importAbcToMusicXml = (abcText: string): CliResult => { try { const xmlText = normalizeImportedMusicXmlText(convertAbcToMusicXml(abcText)); @@ -242,6 +325,16 @@ export const cliApi = { importToMusicXml: importAbcToMusicXml, exportFromMusicXml: exportMusicXmlToAbc, }, + fileIO: { + musicxml: { + decodeInput: decodeCliMusicXmlInput, + encodeOutput: encodeCliMusicXmlOutput, + }, + musescore: { + decodeInput: decodeCliMuseScoreInput, + encodeOutput: encodeCliMuseScoreOutput, + }, + }, midi: { importToMusicXml: importMidiToMusicXml, exportFromMusicXml: exportMusicXmlToMidi, @@ -253,4 +346,4 @@ export const cliApi = { render: { svgFromMusicXml: renderMusicXmlToSvg, }, -}; \ No newline at end of file +}; diff --git a/src/ts/download-flow.ts b/src/ts/download-flow.ts index 45e7927..07bcd2f 100644 --- a/src/ts/download-flow.ts +++ b/src/ts/download-flow.ts @@ -22,26 +22,20 @@ import { type MidiExportProfile, } from "./midi-musescore-io"; import { parseMusicXmlDocument, prettyPrintMusicXmlText } from "./musicxml-io"; +import { + bytesToArrayBuffer, + formatXmlWithTwoSpaceIndent, + makeMsczBytes, + makeMxlBytes, + makeZipBytes, + type ZipEntryPayload, +} from "./zip-io"; export type DownloadFilePayload = { fileName: string; blob: Blob; }; -type ZipEntryPayload = { - path: string; - bytes: Uint8Array; -}; - -type EncodedZipEntry = { - pathBytes: Uint8Array; - data: Uint8Array; - crc: number; - method: 0 | 8; - compressedSize: number; - uncompressedSize: number; -}; - const pad2 = (value: number): string => String(value).padStart(2, "0"); const buildFileTimestamp = (): string => { @@ -55,23 +49,6 @@ const buildFileTimestamp = (): string => { ].join(""); }; -const prettyPrintXmlWithTwoSpaceIndent = (xml: string): string => { - const compact = String(xml || "").replace(/>\s+<").trim(); - const split = compact.replace(/(>)(<)(\/*)/g, "$1\n$2$3").split("\n"); - let indentLevel = 0; - const lines: string[] = []; - for (const rawToken of split) { - const token = rawToken.trim(); - if (!token) continue; - if (/^<\//.test(token)) indentLevel = Math.max(0, indentLevel - 1); - lines.push(`${" ".repeat(indentLevel)}${token}`); - const isOpening = /^<[^!?/][^>]*>$/.test(token); - const isSelfClosing = /\/>$/.test(token); - if (isOpening && !isSelfClosing) indentLevel += 1; - } - return lines.join("\n"); -}; - export const triggerFileDownload = (payload: DownloadFilePayload): void => { const url = URL.createObjectURL(payload.blob); const a = document.createElement("a"); @@ -81,186 +58,6 @@ export const triggerFileDownload = (payload: DownloadFilePayload): void => { URL.revokeObjectURL(url); }; -const crc32Table = (() => { - const table = new Uint32Array(256); - for (let n = 0; n < 256; n += 1) { - let c = n; - for (let k = 0; k < 8; k += 1) { - c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); - } - table[n] = c >>> 0; - } - return table; -})(); - -const crc32 = (bytes: Uint8Array): number => { - let crc = 0xffffffff; - for (let i = 0; i < bytes.length; i += 1) { - crc = crc32Table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); - } - return (crc ^ 0xffffffff) >>> 0; -}; - -const writeU16 = (target: Uint8Array, offset: number, value: number): void => { - target[offset] = value & 0xff; - target[offset + 1] = (value >>> 8) & 0xff; -}; - -const writeU32 = (target: Uint8Array, offset: number, value: number): void => { - target[offset] = value & 0xff; - target[offset + 1] = (value >>> 8) & 0xff; - target[offset + 2] = (value >>> 16) & 0xff; - target[offset + 3] = (value >>> 24) & 0xff; -}; - -const toDosDateTime = (date: Date): { dosTime: number; dosDate: number } => { - const year = Math.max(1980, Math.min(2107, date.getFullYear())); - const month = Math.max(1, Math.min(12, date.getMonth() + 1)); - const day = Math.max(1, Math.min(31, date.getDate())); - const hours = Math.max(0, Math.min(23, date.getHours())); - const minutes = Math.max(0, Math.min(59, date.getMinutes())); - const seconds = Math.max(0, Math.min(59, date.getSeconds())); - const dosTime = ((hours & 0x1f) << 11) | ((minutes & 0x3f) << 5) | ((Math.floor(seconds / 2)) & 0x1f); - const dosDate = (((year - 1980) & 0x7f) << 9) | ((month & 0x0f) << 5) | (day & 0x1f); - return { dosTime, dosDate }; -}; - -const compressDeflateRaw = async (input: Uint8Array): Promise => { - const CS = (globalThis as { CompressionStream?: new (format: string) => unknown }).CompressionStream; - if (!CS) return null; - try { - const source = new Uint8Array(input.length); - source.set(input); - const stream = new Blob([bytesToArrayBuffer(source)]).stream().pipeThrough(new CS("deflate-raw") as never); - const compressedBuffer = await new Response(stream).arrayBuffer(); - return new Uint8Array(compressedBuffer); - } catch { - return null; - } -}; - -const makeZipBytes = async (entries: ZipEntryPayload[], preferCompression: boolean): Promise => { - const encoder = new TextEncoder(); - const localChunks: Uint8Array[] = []; - const centralChunks: Uint8Array[] = []; - let localOffset = 0; - const nowDos = toDosDateTime(new Date()); - - const encodedEntries: EncodedZipEntry[] = []; - for (const entry of entries) { - const pathBytes = encoder.encode(entry.path.replace(/\\/g, "/").replace(/^\/+/, "")); - const uncompressed = entry.bytes; - let data = uncompressed; - let method: 0 | 8 = 0; - if (preferCompression) { - const compressed = await compressDeflateRaw(uncompressed); - if (compressed && compressed.length < uncompressed.length) { - data = compressed; - method = 8; - } - } - encodedEntries.push({ - pathBytes, - data, - crc: crc32(uncompressed), - method, - compressedSize: data.length, - uncompressedSize: uncompressed.length, - }); - } - - for (const entry of encodedEntries) { - const { pathBytes, data, crc, method, compressedSize, uncompressedSize } = entry; - - const localHeader = new Uint8Array(30 + pathBytes.length); - writeU32(localHeader, 0, 0x04034b50); - writeU16(localHeader, 4, 20); - writeU16(localHeader, 6, 0x0800); - writeU16(localHeader, 8, method); - writeU16(localHeader, 10, nowDos.dosTime); - writeU16(localHeader, 12, nowDos.dosDate); - writeU32(localHeader, 14, crc); - writeU32(localHeader, 18, compressedSize); - writeU32(localHeader, 22, uncompressedSize); - writeU16(localHeader, 26, pathBytes.length); - writeU16(localHeader, 28, 0); - localHeader.set(pathBytes, 30); - localChunks.push(localHeader, data); - - const centralHeader = new Uint8Array(46 + pathBytes.length); - writeU32(centralHeader, 0, 0x02014b50); - writeU16(centralHeader, 4, 20); - writeU16(centralHeader, 6, 20); - writeU16(centralHeader, 8, 0x0800); - writeU16(centralHeader, 10, method); - writeU16(centralHeader, 12, nowDos.dosTime); - writeU16(centralHeader, 14, nowDos.dosDate); - writeU32(centralHeader, 16, crc); - writeU32(centralHeader, 20, compressedSize); - writeU32(centralHeader, 24, uncompressedSize); - writeU16(centralHeader, 28, pathBytes.length); - writeU16(centralHeader, 30, 0); - writeU16(centralHeader, 32, 0); - writeU16(centralHeader, 34, 0); - writeU16(centralHeader, 36, 0); - writeU32(centralHeader, 38, 0); - writeU32(centralHeader, 42, localOffset); - centralHeader.set(pathBytes, 46); - centralChunks.push(centralHeader); - - localOffset += localHeader.length + compressedSize; - } - - const localSize = localChunks.reduce((sum, b) => sum + b.length, 0); - const centralSize = centralChunks.reduce((sum, b) => sum + b.length, 0); - const eocd = new Uint8Array(22); - writeU32(eocd, 0, 0x06054b50); - writeU16(eocd, 4, 0); - writeU16(eocd, 6, 0); - writeU16(eocd, 8, entries.length); - writeU16(eocd, 10, entries.length); - writeU32(eocd, 12, centralSize); - writeU32(eocd, 16, localSize); - writeU16(eocd, 20, 0); - - const out = new Uint8Array(localSize + centralSize + eocd.length); - let cursor = 0; - for (const chunk of localChunks) { - out.set(chunk, cursor); - cursor += chunk.length; - } - for (const chunk of centralChunks) { - out.set(chunk, cursor); - cursor += chunk.length; - } - out.set(eocd, cursor); - return out; -}; - -const makeMxlBytes = async (formattedXml: string): Promise => { - const encoder = new TextEncoder(); - const containerXml = - `` + - `` + - `` + - ``; - return makeZipBytes([ - { path: "META-INF/container.xml", bytes: encoder.encode(containerXml) }, - { path: "score.musicxml", bytes: encoder.encode(formattedXml) }, - ], true); -}; - -const makeMsczBytes = async (mscxText: string): Promise => { - const encoder = new TextEncoder(); - return makeZipBytes([{ path: "score.mscx", bytes: encoder.encode(mscxText) }], true); -}; - -const bytesToArrayBuffer = (bytes: Uint8Array): ArrayBuffer => { - const out = new ArrayBuffer(bytes.byteLength); - new Uint8Array(out).set(bytes); - return out; -}; - export const createMusicXmlDownloadPayload = async ( xmlText: string, options: { compressed?: boolean; useXmlExtension?: boolean } = {} @@ -299,7 +96,7 @@ export const createJsonDownloadPayload = (jsonText: string, stem = "measure-deta export const createVsqxDownloadPayload = (vsqxText: string): DownloadFilePayload => { const ts = buildFileTimestamp(); - const formattedVsqx = prettyPrintXmlWithTwoSpaceIndent(vsqxText); + const formattedVsqx = formatXmlWithTwoSpaceIndent(vsqxText); return { fileName: `mikuscore-${ts}.vsqx`, blob: new Blob([formattedVsqx], { type: "application/xml;charset=utf-8" }), @@ -475,7 +272,7 @@ export const createMuseScoreDownloadPayload = async ( } catch { return null; } - const formattedMscx = prettyPrintXmlWithTwoSpaceIndent(mscxText); + const formattedMscx = formatXmlWithTwoSpaceIndent(mscxText); const ts = buildFileTimestamp(); if (options.compressed === true) { @@ -509,4 +306,4 @@ export const createZipBundleDownloadPayload = async ( fileName: `${safeBase}-${ts}.zip`, blob: new Blob([bytesToArrayBuffer(zipBytes)], { type: "application/zip" }), }; -}; \ No newline at end of file +}; diff --git a/src/ts/mxl-io.ts b/src/ts/mxl-io.ts index 340a7b6..b7167ab 100644 --- a/src/ts/mxl-io.ts +++ b/src/ts/mxl-io.ts @@ -3,256 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -type ZipEntry = { - path: string; - compressionMethod: number; - compressedSize: number; - uncompressedSize: number; - dataOffset: number; -}; - -const ZIP_EOCD_SIG = 0x06054b50; -const ZIP_CDFH_SIG = 0x02014b50; -const ZIP_LFH_SIG = 0x04034b50; - -const readU16 = (bytes: Uint8Array, offset: number): number => { - return bytes[offset] | (bytes[offset + 1] << 8); -}; - -const readU32 = (bytes: Uint8Array, offset: number): number => { - return ( - bytes[offset] | - (bytes[offset + 1] << 8) | - (bytes[offset + 2] << 16) | - (bytes[offset + 3] << 24) - ) >>> 0; -}; - -const normalizeZipPath = (value: string): string => { - return value.replace(/\\/g, "/").replace(/^\.?\//, ""); -}; - -const decodeZipFileName = (bytes: Uint8Array, utf8Flag: boolean): string => { - if (utf8Flag) return new TextDecoder("utf-8").decode(bytes); - let out = ""; - for (const b of bytes) out += String.fromCharCode(b); - return out; -}; - -const findEndOfCentralDirectoryOffset = (bytes: Uint8Array): number => { - // EOCD is within the last 65,557 bytes by ZIP spec. - const minOffset = Math.max(0, bytes.length - 65557); - for (let offset = bytes.length - 22; offset >= minOffset; offset -= 1) { - if (readU32(bytes, offset) === ZIP_EOCD_SIG) return offset; - } - return -1; -}; - -const readZipEntries = (bytes: Uint8Array): ZipEntry[] => { - const eocdOffset = findEndOfCentralDirectoryOffset(bytes); - if (eocdOffset < 0) throw new Error("Invalid ZIP: end of central directory was not found."); - - const centralDirectorySize = readU32(bytes, eocdOffset + 12); - const centralDirectoryOffset = readU32(bytes, eocdOffset + 16); - const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize; - if (centralDirectoryEnd > bytes.length) { - throw new Error("Invalid ZIP: central directory is out of range."); - } - - const entries: ZipEntry[] = []; - let offset = centralDirectoryOffset; - while (offset < centralDirectoryEnd) { - if (readU32(bytes, offset) !== ZIP_CDFH_SIG) { - throw new Error("Invalid ZIP: central directory entry is malformed."); - } - - const flags = readU16(bytes, offset + 8); - const compressionMethod = readU16(bytes, offset + 10); - const compressedSize = readU32(bytes, offset + 20); - const uncompressedSize = readU32(bytes, offset + 24); - const fileNameLength = readU16(bytes, offset + 28); - const extraLength = readU16(bytes, offset + 30); - const commentLength = readU16(bytes, offset + 32); - const localHeaderOffset = readU32(bytes, offset + 42); - - const fileNameStart = offset + 46; - const fileNameEnd = fileNameStart + fileNameLength; - if (fileNameEnd > bytes.length) { - throw new Error("Invalid ZIP: entry filename is out of range."); - } - const fileName = decodeZipFileName(bytes.slice(fileNameStart, fileNameEnd), (flags & 0x0800) !== 0); - const normalizedPath = normalizeZipPath(fileName); - - if (localHeaderOffset + 30 > bytes.length || readU32(bytes, localHeaderOffset) !== ZIP_LFH_SIG) { - throw new Error(`Invalid ZIP: local header is missing for "${normalizedPath}".`); - } - const localNameLength = readU16(bytes, localHeaderOffset + 26); - const localExtraLength = readU16(bytes, localHeaderOffset + 28); - const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength; - if (dataOffset + compressedSize > bytes.length) { - throw new Error(`Invalid ZIP: data is out of range for "${normalizedPath}".`); - } - - if (normalizedPath && !normalizedPath.endsWith("/")) { - entries.push({ - path: normalizedPath, - compressionMethod, - compressedSize, - uncompressedSize, - dataOffset, - }); - } - - offset = fileNameEnd + extraLength + commentLength; - } - - return entries; -}; - -const inflateDeflateRaw = async (compressed: Uint8Array): Promise => { - const DS = (globalThis as { DecompressionStream?: new (format: string) => unknown }).DecompressionStream; - if (!DS) { - throw new Error("DecompressionStream is not available in this browser."); - } - - const copied = new Uint8Array(compressed.length); - copied.set(compressed); - const stream = new Blob([copied.buffer]).stream().pipeThrough(new DS("deflate-raw") as never); - const arrayBuffer = await new Response(stream).arrayBuffer(); - return new Uint8Array(arrayBuffer); -}; - -const extractEntryBytes = async (archiveBytes: Uint8Array, entry: ZipEntry): Promise => { - const compressed = archiveBytes.slice(entry.dataOffset, entry.dataOffset + entry.compressedSize); - if (entry.compressionMethod === 0) { - return compressed; - } - if (entry.compressionMethod === 8) { - const inflated = await inflateDeflateRaw(compressed); - if (entry.uncompressedSize > 0 && inflated.length !== entry.uncompressedSize) { - // Keep going: some archives are inconsistent here, but data is often still valid. - } - return inflated; - } - throw new Error(`Unsupported ZIP compression method: ${entry.compressionMethod}.`); -}; - -const findEntryByPath = (entries: ZipEntry[], path: string): ZipEntry | null => { - const normalized = normalizeZipPath(path); - return entries.find((entry) => entry.path === normalized) ?? null; -}; - -const findLikelyMusicXmlEntry = (entries: ZipEntry[]): ZipEntry | null => { - for (const entry of entries) { - const p = entry.path.toLowerCase(); - if (p.endsWith(".musicxml")) return entry; - } - for (const entry of entries) { - const p = entry.path.toLowerCase(); - if (p.endsWith(".xml") && p !== "meta-inf/container.xml") return entry; - } - return null; -}; - -const findFirstEntryByExtensions = (entries: ZipEntry[], extensions: string[]): ZipEntry | null => { - const normalized = extensions.map((ext) => ext.trim().toLowerCase()).filter((ext) => ext.length > 0); - if (!normalized.length) return null; - for (const entry of entries) { - const p = entry.path.toLowerCase(); - if (normalized.some((ext) => p.endsWith(ext))) return entry; - } - return null; -}; - -const listRootEntriesByExtensions = (entries: ZipEntry[], extensions: string[]): ZipEntry[] => { - const normalized = extensions.map((ext) => ext.trim().toLowerCase()).filter((ext) => ext.length > 0); - if (!normalized.length) return []; - return entries.filter((entry) => { - if (entry.path.includes("/")) return false; - const p = entry.path.toLowerCase(); - return normalized.some((ext) => p.endsWith(ext)); - }); -}; - -const parseContainerRootFilePath = (containerXmlText: string): string | null => { - const doc = new DOMParser().parseFromString(containerXmlText, "application/xml"); - if (doc.querySelector("parsererror")) return null; - const rootFileNode = doc.querySelector("rootfile[full-path]"); - const fullPath = rootFileNode?.getAttribute("full-path")?.trim() ?? ""; - return fullPath || null; -}; - -export const extractMusicXmlTextFromMxl = async (archiveBuffer: ArrayBuffer): Promise => { - const archiveBytes = new Uint8Array(archiveBuffer); - const entries = readZipEntries(archiveBytes); - if (entries.length === 0) { - throw new Error("The MXL archive is empty."); - } - - const containerEntry = findEntryByPath(entries, "META-INF/container.xml"); - if (containerEntry) { - const containerBytes = await extractEntryBytes(archiveBytes, containerEntry); - const containerText = new TextDecoder("utf-8").decode(containerBytes); - const rootPath = parseContainerRootFilePath(containerText); - if (rootPath) { - const rootEntry = findEntryByPath(entries, rootPath); - if (!rootEntry) { - throw new Error(`MusicXML root file was not found in archive: ${rootPath}`); - } - const xmlBytes = await extractEntryBytes(archiveBytes, rootEntry); - return new TextDecoder("utf-8").decode(xmlBytes); - } - } - - const fallbackEntry = findLikelyMusicXmlEntry(entries); - if (!fallbackEntry) { - throw new Error("No MusicXML file (.musicxml or .xml) was found in the MXL archive."); - } - const xmlBytes = await extractEntryBytes(archiveBytes, fallbackEntry); - return new TextDecoder("utf-8").decode(xmlBytes); -}; - -export const extractTextFromZipByExtensions = async ( - archiveBuffer: ArrayBuffer, - extensions: string[] -): Promise => { - const archiveBytes = new Uint8Array(archiveBuffer); - const entries = readZipEntries(archiveBytes); - if (!entries.length) { - throw new Error("The ZIP archive is empty."); - } - const entry = findFirstEntryByExtensions(entries, extensions); - if (!entry) { - throw new Error(`No matching entry was found for extensions: ${extensions.join(", ")}`); - } - const bytes = await extractEntryBytes(archiveBytes, entry); - return new TextDecoder("utf-8").decode(bytes); -}; - -export const listZipRootEntryPathsByExtensions = async ( - archiveBuffer: ArrayBuffer, - extensions: string[] -): Promise => { - const archiveBytes = new Uint8Array(archiveBuffer); - const entries = readZipEntries(archiveBytes); - if (!entries.length) { - throw new Error("The ZIP archive is empty."); - } - return listRootEntriesByExtensions(entries, extensions).map((entry) => entry.path); -}; - -export const extractZipEntryBytesByPath = async ( - archiveBuffer: ArrayBuffer, - entryPath: string -): Promise => { - const archiveBytes = new Uint8Array(archiveBuffer); - const entries = readZipEntries(archiveBytes); - if (!entries.length) { - throw new Error("The ZIP archive is empty."); - } - const entry = findEntryByPath(entries, entryPath); - if (!entry) { - throw new Error(`ZIP entry not found: ${entryPath}`); - } - return extractEntryBytes(archiveBytes, entry); -}; \ No newline at end of file +export { + extractMusicXmlTextFromMxl, + extractTextFromZipByExtensions, + extractZipEntryBytesByPath, + listZipRootEntryPathsByExtensions, +} from "./zip-io"; diff --git a/src/ts/zip-io.ts b/src/ts/zip-io.ts new file mode 100644 index 0000000..be30b45 --- /dev/null +++ b/src/ts/zip-io.ts @@ -0,0 +1,471 @@ +/* + * Copyright 2026 Toshiki Iga + * SPDX-License-Identifier: Apache-2.0 + */ + +export type ZipEntry = { + path: string; + compressionMethod: number; + compressedSize: number; + uncompressedSize: number; + dataOffset: number; +}; + +export type ZipEntryPayload = { + path: string; + bytes: Uint8Array; +}; + +type EncodedZipEntry = { + pathBytes: Uint8Array; + data: Uint8Array; + crc: number; + method: 0 | 8; + compressedSize: number; + uncompressedSize: number; +}; + +const ZIP_EOCD_SIG = 0x06054b50; +const ZIP_CDFH_SIG = 0x02014b50; +const ZIP_LFH_SIG = 0x04034b50; + +const readU16 = (bytes: Uint8Array, offset: number): number => { + return bytes[offset] | (bytes[offset + 1] << 8); +}; + +const readU32 = (bytes: Uint8Array, offset: number): number => { + return ( + bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24) + ) >>> 0; +}; + +const normalizeZipPath = (value: string): string => { + return value.replace(/\\/g, "/").replace(/^\.?\//, ""); +}; + +const decodeZipFileName = (bytes: Uint8Array, utf8Flag: boolean): string => { + if (utf8Flag) return new TextDecoder("utf-8").decode(bytes); + let out = ""; + for (const b of bytes) out += String.fromCharCode(b); + return out; +}; + +const findEndOfCentralDirectoryOffset = (bytes: Uint8Array): number => { + const minOffset = Math.max(0, bytes.length - 65557); + for (let offset = bytes.length - 22; offset >= minOffset; offset -= 1) { + if (readU32(bytes, offset) === ZIP_EOCD_SIG) return offset; + } + return -1; +}; + +const readZipEntries = (bytes: Uint8Array): ZipEntry[] => { + const eocdOffset = findEndOfCentralDirectoryOffset(bytes); + if (eocdOffset < 0) throw new Error("Invalid ZIP: end of central directory was not found."); + + const centralDirectorySize = readU32(bytes, eocdOffset + 12); + const centralDirectoryOffset = readU32(bytes, eocdOffset + 16); + const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize; + if (centralDirectoryEnd > bytes.length) { + throw new Error("Invalid ZIP: central directory is out of range."); + } + + const entries: ZipEntry[] = []; + let offset = centralDirectoryOffset; + while (offset < centralDirectoryEnd) { + if (readU32(bytes, offset) !== ZIP_CDFH_SIG) { + throw new Error("Invalid ZIP: central directory entry is malformed."); + } + + const flags = readU16(bytes, offset + 8); + const compressionMethod = readU16(bytes, offset + 10); + const compressedSize = readU32(bytes, offset + 20); + const uncompressedSize = readU32(bytes, offset + 24); + const fileNameLength = readU16(bytes, offset + 28); + const extraLength = readU16(bytes, offset + 30); + const commentLength = readU16(bytes, offset + 32); + const localHeaderOffset = readU32(bytes, offset + 42); + + const fileNameStart = offset + 46; + const fileNameEnd = fileNameStart + fileNameLength; + if (fileNameEnd > bytes.length) { + throw new Error("Invalid ZIP: entry filename is out of range."); + } + const fileName = decodeZipFileName(bytes.slice(fileNameStart, fileNameEnd), (flags & 0x0800) !== 0); + const normalizedPath = normalizeZipPath(fileName); + + if (localHeaderOffset + 30 > bytes.length || readU32(bytes, localHeaderOffset) !== ZIP_LFH_SIG) { + throw new Error(`Invalid ZIP: local header is missing for "${normalizedPath}".`); + } + const localNameLength = readU16(bytes, localHeaderOffset + 26); + const localExtraLength = readU16(bytes, localHeaderOffset + 28); + const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength; + if (dataOffset + compressedSize > bytes.length) { + throw new Error(`Invalid ZIP: data is out of range for "${normalizedPath}".`); + } + + if (normalizedPath && !normalizedPath.endsWith("/")) { + entries.push({ + path: normalizedPath, + compressionMethod, + compressedSize, + uncompressedSize, + dataOffset, + }); + } + + offset = fileNameEnd + extraLength + commentLength; + } + + return entries; +}; + +const inflateDeflateRaw = async (compressed: Uint8Array): Promise => { + const DS = (globalThis as { DecompressionStream?: new (format: string) => unknown }).DecompressionStream; + if (!DS) { + throw new Error("DecompressionStream is not available in this runtime."); + } + + const copied = new Uint8Array(compressed.length); + copied.set(compressed); + const source = new Response(copied).body; + if (!source) { + throw new Error("DecompressionStream source body is not available in this runtime."); + } + const stream = source.pipeThrough(new DS("deflate-raw") as never); + const arrayBuffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(arrayBuffer); +}; + +const extractEntryBytes = async (archiveBytes: Uint8Array, entry: ZipEntry): Promise => { + const compressed = archiveBytes.slice(entry.dataOffset, entry.dataOffset + entry.compressedSize); + if (entry.compressionMethod === 0) { + return compressed; + } + if (entry.compressionMethod === 8) { + const inflated = await inflateDeflateRaw(compressed); + return inflated; + } + throw new Error(`Unsupported ZIP compression method: ${entry.compressionMethod}.`); +}; + +const findEntryByPath = (entries: ZipEntry[], path: string): ZipEntry | null => { + const normalized = normalizeZipPath(path); + return entries.find((entry) => entry.path === normalized) ?? null; +}; + +const findLikelyMusicXmlEntry = (entries: ZipEntry[]): ZipEntry | null => { + for (const entry of entries) { + const p = entry.path.toLowerCase(); + if (p.endsWith(".musicxml")) return entry; + } + for (const entry of entries) { + const p = entry.path.toLowerCase(); + if (p.endsWith(".xml") && p !== "meta-inf/container.xml") return entry; + } + return null; +}; + +const findFirstEntryByExtensions = (entries: ZipEntry[], extensions: string[]): ZipEntry | null => { + const normalized = extensions.map((ext) => ext.trim().toLowerCase()).filter((ext) => ext.length > 0); + if (!normalized.length) return null; + for (const entry of entries) { + const p = entry.path.toLowerCase(); + if (normalized.some((ext) => p.endsWith(ext))) return entry; + } + return null; +}; + +const listRootEntriesByExtensions = (entries: ZipEntry[], extensions: string[]): ZipEntry[] => { + const normalized = extensions.map((ext) => ext.trim().toLowerCase()).filter((ext) => ext.length > 0); + if (!normalized.length) return []; + return entries.filter((entry) => { + if (entry.path.includes("/")) return false; + const p = entry.path.toLowerCase(); + return normalized.some((ext) => p.endsWith(ext)); + }); +}; + +const parseContainerRootFilePath = (containerXmlText: string): string | null => { + const doc = new DOMParser().parseFromString(containerXmlText, "application/xml"); + if (doc.querySelector("parsererror")) return null; + const rootFileNode = doc.querySelector("rootfile[full-path]"); + const fullPath = rootFileNode?.getAttribute("full-path")?.trim() ?? ""; + return fullPath || null; +}; + +const crc32Table = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + } + table[n] = c >>> 0; + } + return table; +})(); + +const crc32 = (bytes: Uint8Array): number => { + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i += 1) { + crc = crc32Table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const writeU16 = (target: Uint8Array, offset: number, value: number): void => { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; +}; + +const writeU32 = (target: Uint8Array, offset: number, value: number): void => { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; + target[offset + 2] = (value >>> 16) & 0xff; + target[offset + 3] = (value >>> 24) & 0xff; +}; + +const toDosDateTime = (date: Date): { dosTime: number; dosDate: number } => { + const year = Math.max(1980, Math.min(2107, date.getFullYear())); + const month = Math.max(1, Math.min(12, date.getMonth() + 1)); + const day = Math.max(1, Math.min(31, date.getDate())); + const hours = Math.max(0, Math.min(23, date.getHours())); + const minutes = Math.max(0, Math.min(59, date.getMinutes())); + const seconds = Math.max(0, Math.min(59, date.getSeconds())); + const dosTime = ((hours & 0x1f) << 11) | ((minutes & 0x3f) << 5) | ((Math.floor(seconds / 2)) & 0x1f); + const dosDate = (((year - 1980) & 0x7f) << 9) | ((month & 0x0f) << 5) | (day & 0x1f); + return { dosTime, dosDate }; +}; + +const compressDeflateRaw = async (input: Uint8Array): Promise => { + const CS = (globalThis as { CompressionStream?: new (format: string) => unknown }).CompressionStream; + if (!CS) return null; + try { + const source = new Uint8Array(input.length); + source.set(input); + const body = new Response(source).body; + if (!body) return null; + const stream = body.pipeThrough(new CS("deflate-raw") as never); + const compressedBuffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(compressedBuffer); + } catch { + return null; + } +}; + +export const formatXmlWithTwoSpaceIndent = (xml: string): string => { + const compact = String(xml || "").replace(/>\s+<").trim(); + const split = compact.replace(/(>)(<)(\/*)/g, "$1\n$2$3").split("\n"); + let indentLevel = 0; + const lines: string[] = []; + for (const rawToken of split) { + const token = rawToken.trim(); + if (!token) continue; + if (/^<\//.test(token)) indentLevel = Math.max(0, indentLevel - 1); + lines.push(`${" ".repeat(indentLevel)}${token}`); + const isOpening = /^<[^!?/][^>]*>$/.test(token); + const isSelfClosing = /\/>$/.test(token); + if (isOpening && !isSelfClosing) indentLevel += 1; + } + return lines.join("\n"); +}; + +export const bytesToArrayBuffer = (bytes: Uint8Array): ArrayBuffer => { + const out = new ArrayBuffer(bytes.byteLength); + new Uint8Array(out).set(bytes); + return out; +}; + +export const makeZipBytes = async (entries: ZipEntryPayload[], preferCompression: boolean): Promise => { + const encoder = new TextEncoder(); + const localChunks: Uint8Array[] = []; + const centralChunks: Uint8Array[] = []; + let localOffset = 0; + const nowDos = toDosDateTime(new Date()); + + const encodedEntries: EncodedZipEntry[] = []; + for (const entry of entries) { + const pathBytes = encoder.encode(entry.path.replace(/\\/g, "/").replace(/^\/+/, "")); + const uncompressed = entry.bytes; + let data = uncompressed; + let method: 0 | 8 = 0; + if (preferCompression) { + const compressed = await compressDeflateRaw(uncompressed); + if (compressed && compressed.length < uncompressed.length) { + data = compressed; + method = 8; + } + } + encodedEntries.push({ + pathBytes, + data, + crc: crc32(uncompressed), + method, + compressedSize: data.length, + uncompressedSize: uncompressed.length, + }); + } + + for (const entry of encodedEntries) { + const { pathBytes, data, crc, method, compressedSize, uncompressedSize } = entry; + + const localHeader = new Uint8Array(30 + pathBytes.length); + writeU32(localHeader, 0, 0x04034b50); + writeU16(localHeader, 4, 20); + writeU16(localHeader, 6, 0x0800); + writeU16(localHeader, 8, method); + writeU16(localHeader, 10, nowDos.dosTime); + writeU16(localHeader, 12, nowDos.dosDate); + writeU32(localHeader, 14, crc); + writeU32(localHeader, 18, compressedSize); + writeU32(localHeader, 22, uncompressedSize); + writeU16(localHeader, 26, pathBytes.length); + writeU16(localHeader, 28, 0); + localHeader.set(pathBytes, 30); + localChunks.push(localHeader, data); + + const centralHeader = new Uint8Array(46 + pathBytes.length); + writeU32(centralHeader, 0, 0x02014b50); + writeU16(centralHeader, 4, 20); + writeU16(centralHeader, 6, 20); + writeU16(centralHeader, 8, 0x0800); + writeU16(centralHeader, 10, method); + writeU16(centralHeader, 12, nowDos.dosTime); + writeU16(centralHeader, 14, nowDos.dosDate); + writeU32(centralHeader, 16, crc); + writeU32(centralHeader, 20, compressedSize); + writeU32(centralHeader, 24, uncompressedSize); + writeU16(centralHeader, 28, pathBytes.length); + writeU16(centralHeader, 30, 0); + writeU16(centralHeader, 32, 0); + writeU16(centralHeader, 34, 0); + writeU16(centralHeader, 36, 0); + writeU32(centralHeader, 38, 0); + writeU32(centralHeader, 42, localOffset); + centralHeader.set(pathBytes, 46); + centralChunks.push(centralHeader); + + localOffset += localHeader.length + compressedSize; + } + + const localSize = localChunks.reduce((sum, b) => sum + b.length, 0); + const centralSize = centralChunks.reduce((sum, b) => sum + b.length, 0); + const eocd = new Uint8Array(22); + writeU32(eocd, 0, 0x06054b50); + writeU16(eocd, 4, 0); + writeU16(eocd, 6, 0); + writeU16(eocd, 8, entries.length); + writeU16(eocd, 10, entries.length); + writeU32(eocd, 12, centralSize); + writeU32(eocd, 16, localSize); + writeU16(eocd, 20, 0); + + const out = new Uint8Array(localSize + centralSize + eocd.length); + let cursor = 0; + for (const chunk of localChunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + for (const chunk of centralChunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + out.set(eocd, cursor); + return out; +}; + +export const makeMxlBytes = async (formattedXml: string): Promise => { + const encoder = new TextEncoder(); + const containerXml = + `` + + `` + + `` + + ``; + return makeZipBytes([ + { path: "META-INF/container.xml", bytes: encoder.encode(containerXml) }, + { path: "score.musicxml", bytes: encoder.encode(formattedXml) }, + ], true); +}; + +export const makeMsczBytes = async (mscxText: string): Promise => { + const encoder = new TextEncoder(); + return makeZipBytes([{ path: "score.mscx", bytes: encoder.encode(mscxText) }], true); +}; + +export const extractMusicXmlTextFromMxl = async (archiveBuffer: ArrayBuffer): Promise => { + const archiveBytes = new Uint8Array(archiveBuffer); + const entries = readZipEntries(archiveBytes); + if (entries.length === 0) { + throw new Error("The MXL archive is empty."); + } + + const containerEntry = findEntryByPath(entries, "META-INF/container.xml"); + if (containerEntry) { + const containerBytes = await extractEntryBytes(archiveBytes, containerEntry); + const containerText = new TextDecoder("utf-8").decode(containerBytes); + const rootPath = parseContainerRootFilePath(containerText); + if (rootPath) { + const rootEntry = findEntryByPath(entries, rootPath); + if (!rootEntry) { + throw new Error(`MusicXML root file was not found in archive: ${rootPath}`); + } + const xmlBytes = await extractEntryBytes(archiveBytes, rootEntry); + return new TextDecoder("utf-8").decode(xmlBytes); + } + } + + const fallbackEntry = findLikelyMusicXmlEntry(entries); + if (!fallbackEntry) { + throw new Error("No MusicXML file (.musicxml or .xml) was found in the MXL archive."); + } + const xmlBytes = await extractEntryBytes(archiveBytes, fallbackEntry); + return new TextDecoder("utf-8").decode(xmlBytes); +}; + +export const extractTextFromZipByExtensions = async ( + archiveBuffer: ArrayBuffer, + extensions: string[] +): Promise => { + const archiveBytes = new Uint8Array(archiveBuffer); + const entries = readZipEntries(archiveBytes); + if (!entries.length) { + throw new Error("The ZIP archive is empty."); + } + const entry = findFirstEntryByExtensions(entries, extensions); + if (!entry) { + throw new Error(`No matching entry was found for extensions: ${extensions.join(", ")}`); + } + const bytes = await extractEntryBytes(archiveBytes, entry); + return new TextDecoder("utf-8").decode(bytes); +}; + +export const listZipRootEntryPathsByExtensions = async ( + archiveBuffer: ArrayBuffer, + extensions: string[] +): Promise => { + const archiveBytes = new Uint8Array(archiveBuffer); + const entries = readZipEntries(archiveBytes); + if (!entries.length) { + throw new Error("The ZIP archive is empty."); + } + return listRootEntriesByExtensions(entries, extensions).map((entry) => entry.path); +}; + +export const extractZipEntryBytesByPath = async ( + archiveBuffer: ArrayBuffer, + entryPath: string +): Promise => { + const archiveBytes = new Uint8Array(archiveBuffer); + const entries = readZipEntries(archiveBytes); + if (!entries.length) { + throw new Error("The ZIP archive is empty."); + } + const entry = findEntryByPath(entries, entryPath); + if (!entry) { + throw new Error(`ZIP entry not found: ${entryPath}`); + } + return extractEntryBytes(archiveBytes, entry); +}; diff --git a/tests/unit/cli-api.spec.ts b/tests/unit/cli-api.spec.ts index ce80861..c78ee6f 100644 --- a/tests/unit/cli-api.spec.ts +++ b/tests/unit/cli-api.spec.ts @@ -6,6 +6,10 @@ import { describe, expect, it } from "vitest"; import { + decodeCliMuseScoreInput, + decodeCliMusicXmlInput, + encodeCliMuseScoreOutput, + encodeCliMusicXmlOutput, exportMusicXmlToAbc, exportMusicXmlToMidi, exportMusicXmlToMuseScore, @@ -13,6 +17,7 @@ import { importMidiToMusicXml, importMuseScoreToMusicXml, } from "../../src/ts/cli-api"; +import { extractMusicXmlTextFromMxl, extractTextFromZipByExtensions } from "../../src/ts/zip-io"; describe("cli-api", () => { it("imports ABC to MusicXML", () => { @@ -103,6 +108,52 @@ describe("cli-api", () => { expect(result.output).toContain(""); expect(result.output).toContain("Muse export"); }); + + it("decodes .mxl input for CLI file reads", async () => { + const encoded = await encodeCliMusicXmlOutput(validMusicXml("CLI MXL"), "score.mxl"); + expect(encoded.ok).toBe(true); + if (!encoded.ok || typeof encoded.output === "string") return; + + const decoded = await decodeCliMusicXmlInput(encoded.output, "score.mxl"); + expect(decoded.ok).toBe(true); + if (!decoded.ok || typeof decoded.output !== "string") return; + expect(decoded.output).toContain("CLI MXL"); + }); + + it("encodes .mxl output for CLI file writes", async () => { + const result = await encodeCliMusicXmlOutput(validMusicXml("CLI MXL out"), "score.mxl"); + expect(result.ok).toBe(true); + if (!result.ok || typeof result.output === "string") return; + const extracted = await extractMusicXmlTextFromMxl(result.output.buffer.slice( + result.output.byteOffset, + result.output.byteOffset + result.output.byteLength + )); + expect(extracted).toContain("CLI MXL out"); + }); + + it("decodes .mscz input for CLI file reads", async () => { + const encoded = await encodeCliMuseScoreOutput(validMuseScoreXml("CLI MSCZ"), "score.mscz"); + expect(encoded.ok).toBe(true); + if (!encoded.ok || typeof encoded.output === "string") return; + + const decoded = await decodeCliMuseScoreInput(encoded.output, "score.mscz"); + expect(decoded.ok).toBe(true); + if (!decoded.ok || typeof decoded.output !== "string") return; + expect(decoded.output).toContain(""); + expect(decoded.output).toContain("\n "); + }); + + it("encodes .mscz output for CLI file writes", async () => { + const result = await encodeCliMuseScoreOutput(validMuseScoreXml("CLI MSCZ out"), "score.mscz"); + expect(result.ok).toBe(true); + if (!result.ok || typeof result.output === "string") return; + const extracted = await extractTextFromZipByExtensions( + result.output.buffer.slice(result.output.byteOffset, result.output.byteOffset + result.output.byteLength), + [".mscx"] + ); + expect(extracted).toContain(""); + expect(extracted).toContain("\n "); + }); }); function buildSimpleMidi() { @@ -165,4 +216,4 @@ function validMuseScoreXml(title: string) { `; -} \ No newline at end of file +} diff --git a/tests/unit/mikuscore-cli.spec.ts b/tests/unit/mikuscore-cli.spec.ts index 4bb59f7..8e47d79 100644 --- a/tests/unit/mikuscore-cli.spec.ts +++ b/tests/unit/mikuscore-cli.spec.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { afterEach, describe, expect, it } from "vitest"; +import { extractMusicXmlTextFromMxl, extractTextFromZipByExtensions } from "../../src/ts/zip-io"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -66,6 +67,53 @@ describe("mikuscore cli", () => { expect(readFileSync(outPath, "utf8")).toContain(" { + const inputPath = path.resolve(repoRoot, "src", "samples", "musicxml", "sample1.mxl"); + const result = runCli(["convert", "--from", "musicxml", "--to", "abc", "--in", inputPath]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("X:1"); + expect(result.stdout).toContain("K:"); + }); + + it("reads .mscz input files for musescore source", () => { + const inputPath = path.resolve(repoRoot, "src", "samples", "musescore", "sample1.mscz"); + const result = runCli(["convert", "--from", "musescore", "--to", "musicxml", "--in", inputPath]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(" { + const inputPath = writeTempFile("score.abc", "X:1\nT:Zip MusicXML\nM:4/4\nL:1/4\nK:C\nC D E F|\n"); + const outPath = tempPath("out.mxl"); + + const result = runCli(["convert", "--from", "abc", "--to", "musicxml", "--in", inputPath, "--out", outPath]); + + expect(result.status).toBe(0); + const archiveBytes = readFileSync(outPath); + const extracted = await extractMusicXmlTextFromMxl( + archiveBytes.buffer.slice(archiveBytes.byteOffset, archiveBytes.byteOffset + archiveBytes.byteLength) + ); + expect(extracted).toContain("Zip MusicXML"); + }); + + it("writes .mscz output when --out ends with .mscz", async () => { + const inputPath = writeTempFile("score.musicxml", validMusicXml("Zip MuseScore")); + const outPath = tempPath("out.mscz"); + + const result = runCli(["convert", "--from", "musicxml", "--to", "musescore", "--in", inputPath, "--out", outPath]); + + expect(result.status).toBe(0); + const archiveBytes = readFileSync(outPath); + const extracted = await extractTextFromZipByExtensions( + archiveBytes.buffer.slice(archiveBytes.byteOffset, archiveBytes.byteOffset + archiveBytes.byteLength), + [".mscx"] + ); + expect(extracted).toContain(""); + expect(extracted).toContain("\n "); + }); + it("renders SVG from stdin to stdout", () => { const result = runCli(["render", "svg"], { input: validMusicXml("SVG stdout"), @@ -153,4 +201,4 @@ function validMusicXml(title: string) { `; -} \ No newline at end of file +} From d36fe6c1960344fbad29cf7e62d7e4f142f5c0de Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Thu, 16 Apr 2026 09:33:47 +0900 Subject: [PATCH 2/8] =?UTF-8?q?CLI=20=E3=81=AE=20UTF-8=20=E3=83=87?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92=20`TextDecoder`=20?= =?UTF-8?q?=E3=83=99=E3=83=BC=E3=82=B9=E3=81=AB=E5=A4=89=E6=9B=B4=E3=81=97?= =?UTF-8?q?=E3=80=81=E9=96=A2=E9=80=A3=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 CLI の `musicxml` / `musescore` 入力デコードで使っていた `Buffer` 依存を外し、UTF-8 デコードを `TextDecoder` ベースに変更します。あわせて、CLI の入出力契約と UTF-8 テキスト扱いに関する説明をドキュメントへ追記します。 ## 変更内容 - `src/ts/cli-api.ts` - `decodeUtf8Text` を追加 - `decodeCliMusicXmlInput` の平文入力デコードを `Buffer.from(...).toString("utf8")` から `TextDecoder("utf-8").decode(...)` に変更 - `decodeCliMuseScoreInput` の平文入力デコードを `Buffer.from(...).toString("utf8")` から `TextDecoder("utf-8").decode(...)` に変更 - `README.md` - `musicxml` / `musescore` の平文 `stdin` / `stdout` は UTF-8 テキストとして扱い、`.mxl` / `.mscz` の圧縮入出力はファイルパス I/O に限定される旨を追記 - `docs/DEVELOPMENT.md` - CLI の平文デコードが Node 固有の `Buffer` ではなく UTF-8 `TextDecoder` ベースであることを追記 - `docs/spec/CLI_STEP1.md` - `musicxml` / `musescore` の CLI 平文入力デコードは、Node 以外のランタイムとも互換な UTF-8 デコードを使うべきことを追記 ## 影響範囲 - CLI の `musicxml` / `musescore` 平文入力デコード - CLI の仕様説明と開発者向けドキュメント --- README.md | 2 ++ docs/DEVELOPMENT.md | 1 + docs/spec/CLI_STEP1.md | 1 + src/ts/cli-api.ts | 8 ++++++-- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1a041f6..bd0c823 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ mikuscore is for converting, inspecting, and handing score data off. Current CLI is `convert`-first. +For `musicxml` and `musescore`, plain-text `stdin` / `stdout` paths are handled as UTF-8 text, while `.mxl` / `.mscz` compression stays on file-path I/O. + Examples: - `npm run cli -- convert --from abc --to musicxml --in score.abc --out score.musicxml` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 89dc4ec..ae3dc74 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -61,6 +61,7 @@ Input/output contract: - for file output, `--to musicxml` writes `.mxl` when `--out` ends with `.mxl` - for file output, `--to musescore` writes `.mscz` when `--out` ends with `.mscz` - `stdin` / `stdout` remain text-only for `musicxml` and `musescore` +- plain-text CLI decode for `musicxml` / `musescore` is kept on UTF-8 `TextDecoder` rather than Node-only `Buffer`, so the same entrypoint can be runtime-compiled in isolated bundle environments Examples: diff --git a/docs/spec/CLI_STEP1.md b/docs/spec/CLI_STEP1.md index f4b5e9e..a0acf88 100644 --- a/docs/spec/CLI_STEP1.md +++ b/docs/spec/CLI_STEP1.md @@ -194,6 +194,7 @@ Options: - warnings, diagnostics, and summary text SHOULD go to `stderr` - binary output is out of Step 1 scope - compressed `.mxl` / `.mscz` support is limited to file-path I/O; `stdin` / `stdout` remain text-only for `musicxml` and `musescore` +- plain-text decoding for `musicxml` / `musescore` CLI inputs SHOULD use UTF-8 decoding that is compatible with non-Node runtimes as well as Node-based execution ## Error Contract diff --git a/src/ts/cli-api.ts b/src/ts/cli-api.ts index a96334b..0c0457b 100644 --- a/src/ts/cli-api.ts +++ b/src/ts/cli-api.ts @@ -64,13 +64,17 @@ const failureResult = (message: string): CliResult => ({ diagnostics: [message], }); +const decodeUtf8Text = (bytes: Uint8Array): string => { + return new TextDecoder("utf-8").decode(bytes); +}; + export const decodeCliMusicXmlInput = async (inputBytes: Uint8Array, inputPath?: string): Promise => { const name = lowerFileName(inputPath); try { if (name.endsWith(".mxl")) { return textResult(await extractMusicXmlTextFromMxl(bytesToArrayBuffer(inputBytes))); } - return textResult(Buffer.from(inputBytes).toString("utf8")); + return textResult(decodeUtf8Text(inputBytes)); } catch (error) { return failureResult(`Failed to read MusicXML input: ${error instanceof Error ? error.message : String(error)}`); } @@ -85,7 +89,7 @@ export const decodeCliMuseScoreInput = async (inputBytes: Uint8Array, inputPath? [".mscx"] )); } - return textResult(Buffer.from(inputBytes).toString("utf8")); + return textResult(decodeUtf8Text(inputBytes)); } catch (error) { return failureResult(`Failed to read MuseScore input: ${error instanceof Error ? error.message : String(error)}`); } From 204e7cbd7b8687722c320880f7a594b8adfd9df8 Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Fri, 17 Apr 2026 22:23:48 +0900 Subject: [PATCH 3/8] =?UTF-8?q?CLI=20=E3=82=92=20`convert`=20/=20`render`?= =?UTF-8?q?=20/=20`state`=20=E8=BB=B8=E3=81=B8=E6=8B=A1=E5=BC=B5=E3=81=97?= =?UTF-8?q?=E3=80=81`state`=20=E5=88=9D=E6=9C=9F=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=81=A8=E8=A8=BA=E6=96=AD=E5=9F=BA=E7=9B=A4?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 `mikuscore` の CLI を `convert` 中心の構成から、`convert` / `render` / `state` を意識した構成へ広げます。 あわせて、CLI の help・エラーハンドリング・diagnostics の基盤を整理し、`state` 系の初期コマンドを追加します。 ## 変更内容 ### CLI 基盤の改善 - help 出力を一元化 - `CliUsageError` / `CliProcessingError` を導入 - `--diagnostics text|json` を追加 - `--out -` を明示的な `stdout` として扱うよう変更 - usage error と processing error の扱いを分離 - JSON diagnostics の first cut を追加 ### `render` の拡張 - `render svg` に `--from abc` を追加 - 外向きには one-shot な `ABC -> SVG` を提供しつつ、内部は `ABC -> MusicXML -> SVG` の流れを維持 ### `state` 初期コマンドの追加 - `state summarize` - `state inspect-measure` - `state validate-command` - `state apply-command` - `state diff` ### CLI API の拡張 `src/ts/cli-api.ts` に以下を追加し、CLI から canonical `MusicXML` の inspection / validation / mutation / diff を扱えるようにしました。 - `summarizeMusicXmlState(...)` - `inspectMusicXmlMeasure(...)` - `validateMusicXmlCommand(...)` - `applyMusicXmlCommand(...)` - `diffMusicXmlState(...)` ### テスト更新 `tests/unit/mikuscore-cli.spec.ts` を拡張し、以下を含む CLI の振る舞いを検証対象に追加しました。 - top-level / command help - `--out -` - `--diagnostics json` - `render svg --from abc` - `state` 系各コマンド - usage failure / unsupported input の扱い ### ドキュメント更新 README / development note / roadmap / future note / spec 群を更新し、今回の CLI 再構築方針と first cut を文書化しました。 追加した主な spec: - `docs/spec/CLI_TAXONOMY_FIRSTCUT.md` - `docs/spec/CLI_RENDER_FIRSTCUT.md` - `docs/spec/CLI_STATE_FIRSTCUT.md` - `docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md` - `docs/spec/CLI_HELP_FIRSTCUT.md` - `docs/spec/CLI_REBUILD_PLAN.md` ## 意図 - `MusicXML` canonical を維持したまま CLI の責務分離を明確にする - 人間利用、Agent Skills、将来の生成 AI 連携を見据えた CLI 契約へ寄せる - raw な JavaScript 例外露出を減らし、CLI としての failure UX を改善する - 小さい編集機能を `state` 系の bounded mutation workflow として扱える土台を作る ## 補足 - `TODO.md` には、今回の CLI 再構築系列に関する TODO / future direction を追加しています - patch envelope やより深い `state` workflow は今後の拡張対象です --- README.md | 8 +- TODO.md | 101 +++++ docs/DEVELOPMENT.md | 36 +- docs/future/AI_JSON_INTERFACE.md | 30 ++ docs/future/CLI_ROADMAP.md | 169 ++++++++- docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md | 220 +++++++++++ docs/spec/CLI_HELP_FIRSTCUT.md | 164 ++++++++ docs/spec/CLI_REBUILD_PLAN.md | 186 ++++++++++ docs/spec/CLI_RENDER_FIRSTCUT.md | 157 ++++++++ docs/spec/CLI_STATE_FIRSTCUT.md | 246 ++++++++++++ docs/spec/CLI_STEP1.md | 14 + docs/spec/CLI_TAXONOMY_FIRSTCUT.md | 156 ++++++++ scripts/mikuscore-cli.mjs | 514 +++++++++++++++++++++----- src/ts/cli-api.ts | 276 ++++++++++++++ tests/unit/mikuscore-cli.spec.ts | 212 ++++++++++- 15 files changed, 2387 insertions(+), 102 deletions(-) create mode 100644 docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md create mode 100644 docs/spec/CLI_HELP_FIRSTCUT.md create mode 100644 docs/spec/CLI_REBUILD_PLAN.md create mode 100644 docs/spec/CLI_RENDER_FIRSTCUT.md create mode 100644 docs/spec/CLI_STATE_FIRSTCUT.md create mode 100644 docs/spec/CLI_TAXONOMY_FIRSTCUT.md diff --git a/README.md b/README.md index bd0c823..7760dbe 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ mikuscore is for converting, inspecting, and handing score data off. ### CLI -Current CLI is `convert`-first. +Current CLI centers on `convert`, `render`, and an initial `state` family. For `musicxml` and `musescore`, plain-text `stdin` / `stdout` paths are handled as UTF-8 text, while `.mxl` / `.mscz` compression stays on file-path I/O. @@ -84,6 +84,12 @@ Examples: - `npm run cli -- convert --from musescore --to musicxml --in score.mscz --out score.mxl` - `npm run cli -- convert --from musicxml --to musescore --in score.musicxml --out score.mscz` - `npm run cli -- render svg --in score.musicxml --out score.svg` +- `npm run cli -- render svg --from abc --in score.abc --out score.svg` +- `npm run cli -- state summarize --in score.musicxml` +- `npm run cli -- state inspect-measure --measure 1 --in score.musicxml` +- `npm run cli -- state validate-command --in score.musicxml --command-file command.json` +- `npm run cli -- state apply-command --in score.musicxml --command-file command.json --out score.next.musicxml` +- `npm run cli -- state diff --before score.before.musicxml --after score.after.musicxml` For CLI and development details, see `docs/DEVELOPMENT.md` and `docs/spec/CLI_STEP1.md`. diff --git a/TODO.md b/TODO.md index b795c2c..d40c2bf 100644 --- a/TODO.md +++ b/TODO.md @@ -91,6 +91,107 @@ - Cover file input, `stdin`, `--out`, and representative failure cases. - Keep `stdout` for payload and `stderr` for diagnostics only. +- [ ] Record a future-facing CLI design note for AI-mediated workflows. + - Motivation: + - `mikuproject` shows that a CLI can be designed simultaneously for human operators, Agent Skills, and the downstream generative-AI interaction layer + - the valuable lesson is not only "add AI commands", but "design the CLI contract so each layer can use it safely" + - Preserve these candidate principles for future `mikuscore` discussion: + - keep human-readable command naming and composable stdio behavior + - keep the main artifact on `stdout` and diagnostics on `stderr` + - support machine-readable diagnostics when the caller is an agent or another tool + - prefer bounded export / validate / apply-style phases over direct opaque mutation + - design payload units that are small enough for AI handoff, not only for human CLI use + - Likely document homes: + - `docs/future/CLI_ROADMAP.md` + - `docs/future/AI_JSON_INTERFACE.md` + +- [ ] Rebuild CLI taxonomy around `convert` / `render` / `state` while compatibility cost is still low. + - Rationale: + - current real-world CLI usage appears low enough that command-surface reconstruction is still feasible + - `mikuproject` suggests that clearer top-level responsibility split can scale well + - `mikuscore` should keep `convert --from ... --to ...` inside `convert`, rather than multiplying fixed pair commands + - Intended role split: + - `convert`: interchange with external formats + - `render`: derived outputs such as SVG, including user-facing one-shot flows like `ABC -> SVG` even if implemented internally as `ABC -> MusicXML -> SVG` + - `state`: canonical `MusicXML` inspection, validation, patch-style mutation, and other light edit-oriented workflows + - First specification questions: + - whether `state summarize` / `state validate` / `state diff` / `state apply-patch` should be the initial reserved names + - whether `render` should accept non-MusicXML user input and absorb internal conversion stages + - how `--diagnostics text|json` should be shared consistently across all three families + - Concrete next slices: + - write a first-cut CLI taxonomy spec under `docs/spec/` + - define help-text shape for top-level `convert` / `render` / `state` + - decide migration wording from the current `convert`-first CLI to the rebuilt taxonomy + +- [ ] Add a user-facing one-shot `ABC -> SVG` CLI flow without breaking the internal `MusicXML`-first pipeline. + - Intended shape: + - external UX should allow a direct score-rendering path for ABC input + - internal flow should still remain `ABC -> MusicXML -> SVG` + - Specification questions: + - whether this belongs under `render svg` with `--from abc` + - whether `render` should accept only selected non-MusicXML inputs or remain narrow + - how diagnostics should describe both the conversion and render stages when one-shot mode is used + +- [ ] Improve CLI failure handling so uncaught runtime errors stop leaking as raw JavaScript failures. + - Goal: + - turn current unhandled exception behavior into stable CLI-facing usage/processing failures + - First slices: + - define exit-code policy for usage error vs processing error + - ensure stderr messages are human-readable by default + - ensure `--diagnostics json` can still describe failure cases structurally + +- [ ] Define a first-cut CLI diagnostics contract modeled after the successful direction proven in `mikuproject`. + - Scope: + - `convert` + - `render` + - future `state` + - First slices: + - define the minimum shared JSON fields + - decide how warnings vs errors appear in text mode + - define whether multi-stage commands such as one-shot `ABC -> SVG` should report stage summaries + - decide how much "kept vs dropped" conversion information can be surfaced briefly without becoming noisy + +- [ ] Align future `state` CLI naming with the existing core command catalog instead of inventing a second edit model. + - Preserve: + - existing bounded core commands such as `change_to_pitch`, `change_duration`, `insert_note_after`, `delete_note`, and `split_note` + - Prefer: + - workflow-phase CLI names like `state inspect-*`, `state validate-command`, `state apply-command`, `state diff` + - optional patch envelopes if multiple core commands should be validated/applied together + - Avoid: + - exposing each core command as its own top-level CLI verb + - introducing a whole-measure rewrite contract when a bounded command contract is sufficient + +- [ ] Define the `state` first cut around canonical `MusicXML` inspection and bounded mutation. + - Candidate initial commands: + - `state summarize` + - `state inspect-measure` + - `state validate-command` + - `state apply-command` + - `state diff` + - First specification questions: + - whether first cut should expose single-command apply before patch envelopes + - what minimum inspect output is needed to support note-targeted edits reliably + - whether tempo-level light edits should enter through the same bounded command path + +- [ ] Preserve "small edit" work as `MusicXML`-centered bounded mutation, not as a separate editing product line. + - Scope: + - pitch change + - duration change + - note insertion / deletion / split + - likely future tempo-level light edits on canonical `MusicXML` + - Editorial note: + - treat "small edit feature", "`MusicXML`-centered light edit", and "diff-based edit" as the same theme seen from different layers + +- [ ] Explicitly keep some user suggestions out of near-term CLI scope. + - Defer or omit for now: + - batch conversion in CLI itself + - lyrics/melody alignment diagnostics + - MIDI-expression-specific CLI expansion as a priority over `MusicXML`-centered light edits + - Rationale: + - batch orchestration can live outside the CLI if single-shot behavior is composable + - lyrics diagnostics is interesting but currently too heavy for the current first-cut scope + - `mikuscore` should strengthen canonical `MusicXML` editing before expanding MIDI-side tuning controls + ## Facade - [ ] Keep the non-UI CLI facade small and format-oriented. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ae3dc74..9503bfd 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -34,7 +34,7 @@ Generated HTML note: ## CLI Notes -Current CLI uses a `convert`-first command surface. +Current CLI uses a `convert` / `render` / initial `state` command surface. Available commands: @@ -45,6 +45,12 @@ Available commands: - `mikuscore convert --from musescore --to musicxml` - `mikuscore convert --from musicxml --to musescore` - `mikuscore render svg` +- `mikuscore render svg --from abc ...` +- `mikuscore state summarize` +- `mikuscore state inspect-measure` +- `mikuscore state validate-command` +- `mikuscore state apply-command` +- `mikuscore state diff` Input/output contract: @@ -54,6 +60,7 @@ Input/output contract: - omitted `--in` reads from `stdin` - `--out ` writes to file - omitted `--out` writes to `stdout` +- `--out -` writes to `stdout` explicitly - text conversions use text input/output - MIDI input/output uses binary input/output - for file input, `--from musicxml` accepts `.musicxml`, `.xml`, and `.mxl` @@ -61,12 +68,18 @@ Input/output contract: - for file output, `--to musicxml` writes `.mxl` when `--out` ends with `.mxl` - for file output, `--to musescore` writes `.mscz` when `--out` ends with `.mscz` - `stdin` / `stdout` remain text-only for `musicxml` and `musescore` +- `render svg` also accepts `--from abc` as a one-shot path while still routing internally through canonical `MusicXML` +- `state` commands operate on canonical `MusicXML` +- `--diagnostics text|json` is available across current command families - plain-text CLI decode for `musicxml` / `musescore` is kept on UTF-8 `TextDecoder` rather than Node-only `Buffer`, so the same entrypoint can be runtime-compiled in isolated bundle environments +- usage failures now use a distinct CLI error path from processing failures Examples: - `npm run cli -- --help` - `npm run cli -- convert --help` +- `npm run cli -- render --help` +- `npm run cli -- state --help` - `npm run cli -- convert --from abc --to musicxml --in score.abc --out score.musicxml` - `npm run cli -- convert --from musicxml --to abc --in score.musicxml --out score.abc` - `npm run cli -- convert --from midi --to musicxml --in score.mid --out score.musicxml` @@ -77,8 +90,23 @@ Examples: - `npm run cli -- convert --from musescore --to musicxml --in score.mscz --out score.mxl` - `npm run cli -- convert --from musicxml --to musescore --in score.musicxml --out score.mscz` - `npm run cli -- render svg --in score.musicxml --out score.svg` +- `npm run cli -- render svg --from abc --in score.abc --out score.svg` +- `npm run cli -- state summarize --in score.musicxml` +- `npm run cli -- state inspect-measure --measure 12 --in score.musicxml` +- `npm run cli -- state validate-command --in score.musicxml --command-file command.json` +- `npm run cli -- state apply-command --in score.musicxml --command-file command.json --out score.next.musicxml` +- `npm run cli -- state diff --before score.before.musicxml --after score.after.musicxml` - `cat score.abc | npm run cli -- convert --from abc --to musicxml` +Observed sibling-project direction: + +- `mikuproject` CLI grew in a way that intentionally resembles the earlier `mikuscore` CLI surface +- because of that, similarities are expected and should be read as family resemblance, not accidental convergence +- `mikuproject` has also evolved beyond the earlier `mikuscore` baseline, and that direction contains many reusable ideas +- the parts most worth reusing back into `mikuscore` are infrastructure patterns first, not the larger command count itself +- initial reuse is now present in the current CLI via centralized help output, `CliUsageError` / `CliProcessingError`, explicit `--out -`, optional `--diagnostics text|json`, and the first `state` family entrypoints +- if `mikuscore` CLI behavior changes in those areas, update `mikuscore-skills` assumptions as well because downstream agent workflows are sensitive to stderr/stdout and exit-code contracts + ## Documentation Map Contribution and repository policy docs: @@ -113,6 +141,12 @@ Specification docs: - `docs/spec/MIDI_IO.md` - `docs/spec/ABC_IO.md` - `docs/spec/CLI_STEP1.md` +- `docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md` +- `docs/spec/CLI_HELP_FIRSTCUT.md` +- `docs/spec/CLI_REBUILD_PLAN.md` +- `docs/spec/CLI_RENDER_FIRSTCUT.md` +- `docs/spec/CLI_TAXONOMY_FIRSTCUT.md` +- `docs/spec/CLI_STATE_FIRSTCUT.md` - `docs/spec/TEST_MATRIX.md` Future notes: diff --git a/docs/future/AI_JSON_INTERFACE.md b/docs/future/AI_JSON_INTERFACE.md index f9c2820..8f56560 100644 --- a/docs/future/AI_JSON_INTERFACE.md +++ b/docs/future/AI_JSON_INTERFACE.md @@ -29,6 +29,35 @@ Those files should be read as design/archive material unless and until this work - Prefer bounded, validation-friendly exchange rather than unconstrained rewrite. - Reassess whether JSON is actually better than `ABC` for the target workflow before reviving the interface. +## Multi-layer design note + +If this area is resumed, the design should not be framed only as "an AI feature". + +The stronger lesson from the related `mikuproject` work is that the same contract may need to serve three layers at once: + +- human CLI users +- Agent Skills or other tool-mediated callers +- downstream generative-AI interaction that sits behind those tool callers + +That implies several desirable properties: + +- command names and phases should remain understandable to a human operator +- stdio behavior should remain composable in ordinary shell workflows +- diagnostics should have a machine-readable form when the caller is an agent or another program +- AI-facing exchange should prefer bounded projections, validation, and staged apply flows over unconstrained whole-document rewrites +- handoff units should be small enough for reliable AI interaction, not only convenient for a human at a terminal + +For `mikuscore`, one strong candidate is to keep the actual mutation contract close to the existing core command catalog rather than inventing a separate whole-measure rewrite model. + +That would mean: + +- human-facing CLI phases may still look like `state inspect` / `state validate` / `state apply` +- but the machine-facing payload inside those phases may compile down to bounded commands such as `change_to_pitch` or `change_duration` + +This does not mean `mikuscore` should copy the `mikuproject` AI command tree directly. + +It means future AI-facing interface work should be judged partly by how well it serves all three layers together, not only by whether a single AI prompt can produce an output. + ## Re-entry conditions Revisit this only when there is a concrete implementation need such as: @@ -36,6 +65,7 @@ Revisit this only when there is a concrete implementation need such as: - a stable tool-mediated AI workflow - clear bounded edit operations that benefit from a machine-facing contract - evidence that the added interface meaningfully improves reliability over the current ABC-centered flow +- a plausible CLI or tool contract that remains legible for human users while also serving agent-mediated workflows ## Editorial rule diff --git a/docs/future/CLI_ROADMAP.md b/docs/future/CLI_ROADMAP.md index d038939..79ae790 100644 --- a/docs/future/CLI_ROADMAP.md +++ b/docs/future/CLI_ROADMAP.md @@ -3,9 +3,21 @@ ## Status - Step 1 first cut exists. +- CLI infrastructure hardening first cut now exists: + - centralized help output + - usage-error vs processing-error separation + - `--out -` + - `--diagnostics text|json` - Initial Step 2 MIDI pairs now exist as a first cut. - Initial Step 3 MuseScore pairs now exist as a first cut, including `.mscz` / `.mxl` file I/O support. - Initial `render svg` support now exists as a first cut. +- Initial one-shot `render svg --from abc` support now exists as a first cut. +- Initial `state` family first cut now exists: + - `state summarize` + - `state inspect-measure` + - `state validate-command` + - `state apply-command` + - `state diff` - This file tracks likely next-step expansion only. - This is a future note, not a current normative contract. @@ -20,9 +32,16 @@ Current implemented Step 1 scope: - `mikuscore convert --from musescore --to musicxml` - `mikuscore convert --from musicxml --to musescore` - `mikuscore render svg` +- `mikuscore render svg --from abc` +- `mikuscore state summarize` +- `mikuscore state inspect-measure` +- `mikuscore state validate-command` +- `mikuscore state apply-command` +- `mikuscore state diff` - `mikuscore --help` - `mikuscore convert --help` - `mikuscore render --help` +- `mikuscore state --help` Current Step 1 policy is defined in: @@ -30,13 +49,157 @@ Current Step 1 policy is defined in: ## Planned Direction -The CLI family is expected to grow along two tracks: +The current CLI is still `convert`-first, but the next taxonomy candidate is broader: -- convert-oriented commands -- render-oriented commands +- `convert` +- `render` +- `state` `MusicXML` remains canonical underneath. +This means: + +- `convert` handles interchange with external formats +- `render` handles derived outputs such as SVG +- `state` handles canonical `MusicXML` state inspection, validation, patch-style mutation, and other light edit-oriented workflows + +This is intentionally closer to the successful `mikuproject` style of separating command responsibility at the top level. + +At the same time, `mikuscore` should keep `convert --from ... --to ...` for format-pair scaling, rather than exploding the command surface into one fixed command per format pair. + +In other words: + +- top-level command taxonomy should become more structured +- format-pair selection inside `convert` should likely stay option-based + +## Shared CLI Pattern + +The `mikuproject` CLI command family was developed in a shape that intentionally resembles `mikuscore`. + +So the relationship here is not "import a foreign command system into `mikuscore`". + +It is closer to: + +- `mikuscore` established the early `convert`-first CLI pattern +- `mikuproject` expanded that style into a larger command tree +- `mikuproject` later evolved that pattern in ways that are often worth studying back in `mikuscore` +- `mikuscore` can reuse those infrastructure lessons without changing its product identity + +For specification work, `mikuproject` should therefore be treated as an evolved sibling implementation of the same general CLI style. + +The important nuance is: + +- similarity alone does not mean `mikuscore` should copy the larger command surface +- but the direction of `mikuproject` evolution is strong evidence for which CLI infrastructure ideas scale well in practice + +The most reusable infrastructure lessons are: + +- separate usage failures from processing failures, with distinct exit-code policy +- centralize help text generation instead of scattering inline help branches +- support `--out -` explicitly as stdout, not only omitted `--out` +- add optional structured diagnostics such as `--diagnostics text|json` +- validate stdin/file input combinations consistently before command execution +- keep the main artifact on `stdout` and diagnostics on `stderr`, including machine-readable diagnostics when requested + +For `mikuscore`, these are more urgent than broadening the command family again. + +In other words, the next CLI step is likely infrastructure hardening before major surface expansion. + +## Near-Term Candidate + +The earlier CLI infrastructure pass has now landed as a first cut. + +The next strongest near-term candidate is to deepen the current `state` family and tighten documentation/current-contract alignment around the now-implemented `convert` / `render` / `state` surface. + +Likely next slices are: + +- improve current-facing docs to reflect implemented behavior more directly +- decide whether `state inspect-measure` target identity should stay session-scoped `nodeId` based +- deepen `state diff` beyond the current shallow summary if clearer user value emerges +- decide when or whether to introduce patch envelopes after the single-command path has proven itself + +## Candidate Top-Level Taxonomy + +If the CLI is rebuilt while compatibility cost is still low, the strongest current candidate is: + +- `mikuscore convert ...` +- `mikuscore render ...` +- `mikuscore state ...` + +Suggested role split: + +- `convert` + - external interchange only + - example: `mikuscore convert --from abc --to musicxml` +- `render` + - derived artifact generation + - example: `mikuscore render svg --in score.musicxml` + - a user-facing one-shot `ABC -> SVG` flow may still be offered here even if it internally routes through `ABC -> MusicXML -> SVG` +- `state` + - canonical `MusicXML` inspection and mutation + - future examples: `state summarize`, `state validate`, `state diff`, `state apply-patch` + +This shape matches several goals at once: + +- preserve `MusicXML` as the canonical state +- avoid multiplying fixed format-pair commands +- give small edit features and diff-based workflows a natural home +- align better with human CLI use, Agent Skills, and future tool-mediated AI workflows + +The current first-cut specification notes for this direction are: + +- `docs/spec/CLI_TAXONOMY_FIRSTCUT.md` +- `docs/spec/CLI_RENDER_FIRSTCUT.md` +- `docs/spec/CLI_STATE_FIRSTCUT.md` +- `docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md` +- `docs/spec/CLI_HELP_FIRSTCUT.md` +- `docs/spec/CLI_REBUILD_PLAN.md` + +## State Family And Core Command Alignment + +`mikuscore` already has an internal command model for bounded score edits: + +- `change_to_pitch` +- `change_duration` +- `insert_note_after` +- `delete_note` +- `split_note` + +So the CLI should not invent a second unrelated edit model. + +At the same time, those command names do not need to become top-level CLI verbs. + +The more coherent direction is: + +- keep top-level CLI responsibility split as `convert` / `render` / `state` +- let `state` expose phase-oriented commands such as inspect, validate, diff, and apply +- let payloads inside those `state` commands carry the existing core command names + +In practice, that suggests a shape such as: + +- `mikuscore state summarize` +- `mikuscore state inspect-measure` +- `mikuscore state validate-command` +- `mikuscore state apply-command` +- `mikuscore state diff` + +or, if batching several core commands together becomes useful: + +- `mikuscore state validate-patch` +- `mikuscore state apply-patch` + +with payloads that contain one or more existing core commands. + +This keeps the command-line taxonomy consistent with the rest of the CLI while preserving the already-designed internal edit semantics. + +In other words: + +- the CLI surface should be organized around workflow phases +- the payload schema should reuse the current core command catalog +- `mikuscore` should avoid creating separate top-level verbs like `mikuscore change-to-pitch ...` + +That separation is important because it keeps human-facing command discovery manageable while still giving agents and other tools a precise mutation contract. + ## Step 2 Candidate Scope Implemented first-cut Step 2 additions: diff --git a/docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md b/docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md new file mode 100644 index 0000000..0e9757d --- /dev/null +++ b/docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md @@ -0,0 +1,220 @@ +# CLI Diagnostics First Cut + +## Purpose + +This document defines the first candidate shared diagnostics contract for the future `mikuscore` CLI. + +Scope note: + +- this is a first-cut CLI diagnostics note +- this is distinct from the core diagnostics catalog in `docs/spec/DIAGNOSTICS.md` +- this document focuses on CLI-facing error/warning/result reporting + +## Positioning + +The CLI diagnostics contract should work across: + +- `convert` +- `render` +- `state` + +It should also serve multiple callers: + +- human command-line users +- shell/script callers +- Agent Skills and other tool-mediated callers + +## Main Stream Rule + +Across all CLI families: + +- the primary artifact MUST go to `stdout` +- diagnostics MUST go to `stderr` + +Examples: + +- converted `MusicXML` goes to `stdout` +- rendered `SVG` goes to `stdout` +- state summary JSON goes to `stdout` +- warnings/errors/status summaries go to `stderr` + +This rule remains important even for multi-stage commands. + +## Diagnostics Modes + +The strongest current candidate is: + +- `--diagnostics text` +- `--diagnostics json` + +Default: + +- text + +Intended use: + +- text: human-facing CLI use +- json: machine-facing CLI use + +## Text Diagnostics + +Text diagnostics SHOULD: + +- stay short and readable +- preserve the current shell expectation that stderr contains concise warnings/errors +- avoid leaking raw JavaScript stack traces as the normal failure surface + +Text diagnostics MAY include: + +- warnings +- primary failure message +- short stage summary when a command spans multiple internal stages + +## JSON Diagnostics + +JSON diagnostics SHOULD provide a stable minimum shape across top-level families. + +The strongest current candidate minimum fields are: + +```json +{ + "ok": true, + "diagnostics_version": 1, + "command": "render svg", + "context": "render svg", + "status": "success", + "exit_code": 0, + "warning_count": 0, + "error_count": 0, + "io": { + "inputs": [], + "output": { "mode": "stdout" } + }, + "warnings": [], + "errors": [] +} +``` + +Field intent: + +- `ok` + - overall success/failure boolean +- `diagnostics_version` + - version marker for the CLI diagnostics contract +- `command` + - compact command identity +- `context` + - command context string; may equal `command` in first cut +- `status` + - candidate values such as `success`, `warning`, `error` +- `exit_code` + - actual CLI exit code +- `warning_count` + - number of warnings +- `error_count` + - number of errors +- `io` + - summary of input/output locations +- `warnings` + - warning list +- `errors` + - error list + +## Error Classification + +The CLI SHOULD distinguish at least: + +- usage error +- processing error + +Candidate JSON fields for failure cases: + +- `error_type` + - `usage_error` or `processing_error` +- `error_code` + - stable CLI-facing code when available +- `error_details` + - optional machine-facing structured details + +This distinction helps: + +- human debugging +- shell automation +- Agent Skills retry/repair logic + +## Relationship To Core Diagnostics + +Core diagnostics such as: + +- `MEASURE_OVERFULL` +- `MVP_UNSUPPORTED_NON_EDITABLE_VOICE` +- `MVP_INVALID_COMMAND_PAYLOAD` + +remain authoritative for bounded edit semantics. + +The CLI diagnostics contract should wrap those diagnostics rather than replace them. + +That means: + +- CLI diagnostics describe command execution, I/O context, and exit semantics +- core diagnostics describe music-edit validity and execution outcomes inside the bounded command layer + +## Multi-stage Commands + +Some future CLI commands may cross multiple internal stages. + +Example: + +- `render svg --from abc` + - read input + - convert `ABC -> MusicXML` + - render `MusicXML -> SVG` + +First-cut rule: + +- the primary artifact MUST still be only the final output +- diagnostics MAY summarize stages briefly in text mode +- JSON diagnostics SHOULD be able to expose stage-aware context when useful + +Candidate optional JSON extension fields: + +- `stages` +- `output_kind` +- `detected_input_kind` + +These are optional first-cut extensions, not yet required minimum fields. + +## "Kept vs Dropped" Direction + +One important future diagnostics goal is to make `mikuscore` more trustworthy as a converter by making conversion loss easier to notice. + +First-cut direction: + +- diagnostics SHOULD eventually be able to summarize important warnings in short human-facing form +- diagnostics SHOULD avoid pretending to provide a complete musicological diff when they do not +- the first cut may remain conservative and surface only stable, bounded warnings + +## Exit-code Direction + +The strongest current candidate policy is: + +- `0` for success, including success-with-warnings +- non-zero for failure +- usage failures and processing failures SHOULD be distinguishable by code path and, ideally, by exit-code policy + +The exact exit-code split may be finalized later, but the category split should be designed early. + +## First-cut Non-Goals + +This diagnostics note does not yet require: + +- exhaustive stage-by-stage trace output +- a complete diff of every preserved vs dropped notation feature +- parity with every diagnostics shape used in sibling projects +- broad AI-specific diagnostics fields beyond what helps generic tool callers + +## Relationship To Current Docs + +- `docs/spec/DIAGNOSTICS.md` remains the core diagnostics catalog +- this file defines the strongest current candidate shared CLI diagnostics contract +- current implemented CLI behavior remains defined by `docs/spec/CLI_STEP1.md` diff --git a/docs/spec/CLI_HELP_FIRSTCUT.md b/docs/spec/CLI_HELP_FIRSTCUT.md new file mode 100644 index 0000000..00d319d --- /dev/null +++ b/docs/spec/CLI_HELP_FIRSTCUT.md @@ -0,0 +1,164 @@ +# CLI Help First Cut + +## Purpose + +This document defines the first candidate help-text surface for the future `mikuscore` CLI. + +Scope note: + +- this is a first-cut help and discoverability note +- it does not lock the exact final wording +- it is intended to keep the rebuilt CLI understandable to human operators + +## Positioning + +The future CLI is expected to be organized around: + +- `convert` +- `render` +- `state` + +The help surface should reflect that split directly. + +The goal is: + +- clear top-level discovery for humans +- stable enough wording for docs and examples +- no need to understand internal canonical routing before basic use + +## Top-level Help Direction + +The strongest current candidate top-level help shape is: + +```text +Usage: + mikuscore convert --from --to [--in |-] [--out |-] [--diagnostics text|json] + mikuscore render svg [--from ] [--in |-] [--out |-] [--diagnostics text|json] + mikuscore state summarize [--in |-] [--diagnostics text|json] + mikuscore state inspect-measure --measure [--in |-] [--diagnostics text|json] + mikuscore state validate-command [--in |-] [--command |--command-file ] [--diagnostics text|json] + mikuscore state apply-command [--in |-] [--command |--command-file ] [--out |-] [--diagnostics text|json] + mikuscore state diff --before --after [--diagnostics text|json] + mikuscore --help + mikuscore --help + +Commands: + convert Convert score data between external formats + render Generate derived outputs such as SVG + state Inspect, validate, diff, and mutate canonical MusicXML state +``` + +## Design Rules For Help Text + +Top-level help SHOULD: + +- expose the three top-level families directly +- show one strong example for each family +- reveal `--diagnostics text|json` early because it affects both humans and tool callers +- make `stdin` / `stdout` behavior visible + +Top-level help SHOULD NOT: + +- list every future command possibility +- expose internal implementation detail such as forced intermediate `MusicXML` routing +- turn into a long command catalog dump + +## `convert --help` Direction + +The strongest current candidate command help shape is: + +```text +Usage: + mikuscore convert --from --to [--in |-] [--out |-] [--diagnostics text|json] + mikuscore convert --help + +Description: + Convert score data between supported external formats. + +Examples: + mikuscore convert --from abc --to musicxml --in score.abc --out score.musicxml + mikuscore convert --from musicxml --to abc --in score.musicxml --out score.abc + +Notes: + MusicXML remains canonical internally. + Main output goes to stdout unless --out is used. + Diagnostics go to stderr. +``` + +## `render --help` Direction + +The strongest current candidate command help shape is: + +```text +Usage: + mikuscore render svg [--from ] [--in |-] [--out |-] [--diagnostics text|json] + mikuscore render --help + +Description: + Generate derived outputs such as SVG from canonical score state. + +Examples: + mikuscore render svg --in score.musicxml --out score.svg + mikuscore render svg --from abc --in score.abc --out score.svg + +Notes: + A one-shot ABC -> SVG path may internally route through MusicXML. + Main output goes to stdout unless --out is used. + Diagnostics go to stderr. +``` + +## `state --help` Direction + +The strongest current candidate command help shape is: + +```text +Usage: + mikuscore state summarize [--in |-] [--diagnostics text|json] + mikuscore state inspect-measure --measure [--in |-] [--diagnostics text|json] + mikuscore state validate-command [--in |-] [--command |--command-file ] [--diagnostics text|json] + mikuscore state apply-command [--in |-] [--command |--command-file ] [--out |-] [--diagnostics text|json] + mikuscore state diff --before --after [--diagnostics text|json] + mikuscore state --help + +Description: + Inspect, validate, compare, and mutate canonical MusicXML state. + +Examples: + mikuscore state summarize --in score.musicxml + mikuscore state inspect-measure --measure 12 --in score.musicxml + mikuscore state validate-command --in score.musicxml --command-file command.json + mikuscore state apply-command --in score.musicxml --command-file command.json --out score.next.musicxml + mikuscore state diff --before score.before.musicxml --after score.after.musicxml +``` + +## Help Tone + +Help text SHOULD: + +- be compact +- be explicit +- favor example-driven understanding +- name the top-level family responsibility in plain terms + +Help text SHOULD NOT: + +- assume the user already knows the internal data model +- over-explain architectural background inside the help output itself +- mix human-readable examples with large schema dumps + +## Relationship To Diagnostics + +Because diagnostics are now part of the CLI direction, help text SHOULD make this visible early. + +At minimum: + +- `--diagnostics text|json` SHOULD appear in usage lines where supported +- help SHOULD reinforce that the main artifact goes to `stdout` and diagnostics go to `stderr` + +## Relationship To Current Docs + +- `docs/spec/CLI_TAXONOMY_FIRSTCUT.md` defines the top-level future split +- `docs/spec/CLI_RENDER_FIRSTCUT.md` defines the current render first cut +- `docs/spec/CLI_STATE_FIRSTCUT.md` defines the current state first cut +- this file defines the strongest current candidate help-text surface for those command families +- current implemented CLI behavior remains defined by `docs/spec/CLI_STEP1.md` diff --git a/docs/spec/CLI_REBUILD_PLAN.md b/docs/spec/CLI_REBUILD_PLAN.md new file mode 100644 index 0000000..21cbd70 --- /dev/null +++ b/docs/spec/CLI_REBUILD_PLAN.md @@ -0,0 +1,186 @@ +# CLI Rebuild Plan + +## Purpose + +This document defines the first implementation-oriented plan for rebuilding the `mikuscore` CLI around the future taxonomy. + +Scope note: + +- this is a planning and sequencing document +- it does not replace the current implemented CLI contract +- it exists to connect the future CLI specs to an execution order + +## Target Shape + +The current strongest future target is: + +- `convert` +- `render` +- `state` + +with shared CLI diagnostics and clearer help output. + +The goal of this plan is not to deliver everything at once. + +It is to move from the current `convert`-first CLI to the new shape in bounded slices. + +## Guiding Constraints + +- keep `MusicXML` canonical throughout +- avoid introducing a second edit model +- preserve composable CLI behavior: primary artifact on `stdout`, diagnostics on `stderr` +- reduce raw runtime exception leakage early +- sequence implementation so each slice produces usable value on its own + +## Recommended Implementation Order + +### Phase 1. CLI Infrastructure Hardening + +Primary goal: + +- improve the current CLI shell behavior before broadening the visible command surface + +Work items: + +- introduce centralized help output +- introduce usage-error vs processing-error separation +- add `--out -` +- add `--diagnostics text|json` +- define first-cut JSON diagnostics shape + +Why first: + +- this immediately improves failure UX +- later `render` and `state` work both depend on the same CLI contract quality + +Related specs: + +- `docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md` +- `docs/spec/CLI_HELP_FIRSTCUT.md` + +### Phase 2. Render One-shot Improvement + +Primary goal: + +- expose a user-facing direct `ABC -> SVG` path while keeping the internal `MusicXML`-first pipeline + +Work items: + +- extend `render svg` input policy +- decide exact `--from` behavior for `render` +- ensure diagnostics can describe multi-stage render flows + +Why second: + +- this is high visible user value +- it reuses Phase 1 diagnostics and help work +- it does not yet require full `state` mutation machinery + +Related specs: + +- `docs/spec/CLI_RENDER_FIRSTCUT.md` +- `docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md` +- `docs/spec/CLI_HELP_FIRSTCUT.md` + +### Phase 3. Reserve And Introduce `state` + +Primary goal: + +- add the first `state` family entrypoints for canonical `MusicXML` workflows + +Strongest current initial scope: + +- `state summarize` +- `state validate-command` +- `state apply-command` + +Deferred until later in the same family if needed: + +- `state inspect-measure` +- `state diff` +- patch envelopes + +Why this order: + +- `summarize` gives a low-risk first `state` surface +- `validate-command` and `apply-command` align directly with the existing core command catalog +- inspect/diff can be added after the first mutation path is proven + +Related specs: + +- `docs/spec/CLI_STATE_FIRSTCUT.md` +- `docs/spec/COMMAND_CATALOG.md` +- `docs/spec/DIAGNOSTICS.md` + +### Phase 4. Refine State Inspection And Diff + +Primary goal: + +- make bounded edit workflows easier to drive for humans and tools + +Work items: + +- `state inspect-measure` +- `state diff` +- possible command/target selector refinement + +Why later: + +- these commands are useful, but they depend on decisions made in early `state` mutation work + +### Phase 5. Revisit Patch Envelopes + +Primary goal: + +- decide whether `validate-patch` / `apply-patch` are needed after single-command workflows exist + +Why last: + +- first cut can prove the bounded mutation model without immediately committing to batch/patched mutation envelopes + +## Recommended First-code Slice + +If implementation starts now, the strongest initial code slice is: + +1. centralize current help handling +2. add error classification +3. add `--diagnostics text|json` +4. add `--out -` +5. keep current `convert` / `render` behavior otherwise stable + +This lets the current CLI improve materially without yet forcing the whole taxonomy rebuild in one patch. + +## Migration Direction + +Because current CLI usage is believed to be low, the project can tolerate a more direct rebuild than a high-adoption CLI could. + +Still, migration should stay legible. + +Recommended stance: + +- keep migration messaging explicit in help/docs +- prefer one deliberate command-surface revision over long-lived half-compatible hybrids +- keep current specs and future specs both visible until the rebuild lands + +## Deferred Items + +The following remain intentionally out of the near-term rebuild plan: + +- batch conversion as a built-in CLI feature +- lyrics/melody alignment diagnostics +- a broad AI-specific command family +- large MIDI-expression tuning surfaces ahead of canonical `MusicXML` state work + +## Relationship To Other Specs + +- `docs/spec/CLI_TAXONOMY_FIRSTCUT.md` + - top-level future shape +- `docs/spec/CLI_RENDER_FIRSTCUT.md` + - future `render` family +- `docs/spec/CLI_STATE_FIRSTCUT.md` + - future `state` family +- `docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md` + - shared CLI diagnostics contract +- `docs/spec/CLI_HELP_FIRSTCUT.md` + - human-facing help surface +- current implemented CLI behavior remains defined by `docs/spec/CLI_STEP1.md` diff --git a/docs/spec/CLI_RENDER_FIRSTCUT.md b/docs/spec/CLI_RENDER_FIRSTCUT.md new file mode 100644 index 0000000..7e5c7c1 --- /dev/null +++ b/docs/spec/CLI_RENDER_FIRSTCUT.md @@ -0,0 +1,157 @@ +# CLI Render First Cut + +## Purpose + +This document defines the first candidate `render` command family for the future `mikuscore` CLI. + +Scope note: + +- this is a first-cut render workflow spec +- this is not yet the current implemented CLI contract +- it focuses on SVG-oriented rendering because that is the strongest current user-facing render need + +## Positioning + +The `render` family exists to generate derived artifacts from canonical score state. + +For the current first cut, the primary target is: + +- `svg` + +`render` is not the same as external format interchange. + +That means: + +- `convert` is still the home for interchange such as `ABC <-> MusicXML` +- `render` is the home for user-facing outputs such as score SVG + +## First-cut Command Shape + +The strongest current candidate shape is: + +- `mikuscore render svg [--from ] [--in ] [--out ]` + +Examples: + +- `mikuscore render svg --in score.musicxml` +- `mikuscore render svg --from abc --in score.abc` + +## Render Target + +### `render svg` + +Purpose: + +- emit score SVG for visual inspection or lightweight score sharing + +Output: + +- SVG text to `stdout` or `--out ` + +Diagnostics: + +- diagnostics MUST go to `stderr` +- `--diagnostics text|json` SHOULD be supported in the same style as other top-level command families + +## Input Policy + +### Canonical Direction + +Internally, rendering SHOULD continue to treat `MusicXML` as canonical. + +That means the internal pipeline for a direct ABC render MAY be: + +```text +ABC -> MusicXML -> SVG +``` + +without exposing intermediate `MusicXML` to the user unless requested elsewhere. + +### User-facing One-shot Direction + +The current strongest product direction is: + +- `render svg` SHOULD allow at least a direct `ABC -> SVG` path + +Rationale: + +- many users care about "turn this ABC into visible notation" rather than about explicitly seeing the intermediate canonical form +- the internal `MusicXML`-first architecture can remain intact while the CLI becomes more purpose-oriented + +### First-cut Accepted Inputs + +Strongest current candidate: + +- default or explicit `--from musicxml` +- optional `--from abc` for one-shot rendering + +Open question for later: + +- whether first cut should accept additional non-`MusicXML` render inputs such as `midi` or `musescore`, or whether those should stay outside render until the direct value is clearer + +## Shared I/O Direction + +Input: + +- `--in ` reads from file +- omitted `--in` reads from `stdin` +- `--in -` MAY be used as explicit stdin + +Output: + +- `--out ` writes the rendered artifact to file +- omitted `--out` writes to `stdout` +- `--out -` SHOULD be treated as explicit stdout + +## Stage-awareness + +One-shot render commands may cross more than one internal stage. + +Example: + +```text +render svg --from abc +``` + +may internally involve: + +1. decode/read input +2. convert `ABC -> MusicXML` +3. render `MusicXML -> SVG` + +First-cut rule: + +- the main artifact MUST still be only the final SVG +- diagnostics MAY mention stage boundaries briefly +- machine-readable diagnostics SHOULD be able to represent that the command crossed multiple internal stages + +## Relationship To `convert` + +The future CLI SHOULD avoid forcing users to manually spell every internal step when the end goal is obvious. + +So this is acceptable: + +- user asks for score SVG from ABC +- CLI internally converts through canonical `MusicXML` + +This does not weaken the canonical architecture. + +It simply means: + +- internal canonical flow remains `MusicXML` +- external user-facing CLI may be more task-oriented than the internal pipeline + +## First-cut Non-Goals + +The first render cut does not yet require: + +- many render targets beyond SVG +- broad render-option surfaces such as layout tuning, page geometry, or engraving controls +- every supported input format to become a direct render source +- a separate batch render mode inside the CLI itself + +## Relationship To Current Docs + +- `docs/spec/CLI_TAXONOMY_FIRSTCUT.md` defines the top-level future CLI split +- this file defines the strongest current candidate first cut inside `render` +- current implemented CLI behavior remains defined by `docs/spec/CLI_STEP1.md` diff --git a/docs/spec/CLI_STATE_FIRSTCUT.md b/docs/spec/CLI_STATE_FIRSTCUT.md new file mode 100644 index 0000000..ca7e02e --- /dev/null +++ b/docs/spec/CLI_STATE_FIRSTCUT.md @@ -0,0 +1,246 @@ +# CLI State First Cut + +## Purpose + +This document defines the first candidate `state` command family for the future `mikuscore` CLI. + +Scope note: + +- this is a first-cut state workflow spec +- current CLI implementation now covers most of this first cut, though payload details may still evolve +- detailed payload schemas may be refined later + +## Positioning + +The `state` family exists to operate on canonical `MusicXML` state. + +It is intended for workflows that are not just format interchange and not just derived rendering. + +Typical use cases include: + +- inspect the current score state briefly +- inspect a specific measure before making a bounded edit +- validate one bounded command before applying it +- apply one bounded command and write the next `MusicXML` state +- compare two `MusicXML` states + +`state` MUST NOT introduce a second canonical score model. + +## Relationship To Other Top-level Families + +- `convert` + - handles interchange with external formats +- `render` + - handles derived artifacts such as SVG +- `state` + - handles canonical `MusicXML` inspection, validation, diff, and bounded mutation + +This means a flow such as: + +```text +ABC -> MusicXML -> inspect/validate/apply -> SVG +``` + +may cross multiple top-level families, but `state` itself remains centered on canonical `MusicXML`. + +## First-cut Command Set + +The strongest current candidate first cut is: + +- `mikuscore state summarize` +- `mikuscore state inspect-measure` +- `mikuscore state validate-command` +- `mikuscore state apply-command` +- `mikuscore state diff` + +If patch envelopes become necessary later, likely additions are: + +- `mikuscore state validate-patch` +- `mikuscore state apply-patch` + +## Shared I/O Direction + +Input: + +- `state` commands SHOULD accept canonical `MusicXML` from `--in ` or `stdin` +- commands that compare two states MAY use paired options such as `--before` and `--after` + +Output: + +- the primary artifact MUST go to `stdout` unless `--out ` is used +- diagnostics MUST go to `stderr` +- `--out -` SHOULD be treated as explicit stdout + +Diagnostics: + +- first-cut direction is `--diagnostics text|json` +- text is the default human-facing mode +- json is the machine-facing mode for Agent Skills and other tool callers + +## Command Semantics + +### 1. `state summarize` + +Purpose: + +- provide a compact summary of canonical `MusicXML` state + +Intended output shape: + +- machine-readable summary +- enough information to confirm that the score loaded as expected +- not a full serialization of the whole score + +Candidate summary fields: + +- part count +- measure count +- available measure numbers +- available voices or lanes when cheaply derivable +- title / metadata when available + +### 2. `state inspect-measure` + +Purpose: + +- inspect one target measure before a bounded edit + +Input: + +- canonical `MusicXML` +- a target selector such as measure number + +Intended output shape: + +- compact measure-focused view +- enough information to identify note targets for later bounded commands + +First-cut design rule: + +- inspect output SHOULD be smaller and easier to reason about than raw full-document `MusicXML` +- inspect output SHOULD still preserve enough identity information to target a later mutation reliably + +Current first-cut direction: + +- inspect output currently returns a hybrid targeting hint +- session-scoped `node_id` is preserved for direct command payload use +- selector metadata such as part, measure, and note position is also returned to make workflows easier to explain and debug + +### 3. `state validate-command` + +Purpose: + +- validate one bounded mutation command against the current canonical `MusicXML` state without mutating output state + +Input: + +- canonical `MusicXML` +- one machine-facing bounded command payload + +Behavior: + +- MUST reuse the existing core command catalog semantics +- MUST NOT invent a separate whole-measure rewrite contract +- MUST return success or failure with diagnostics +- MUST keep mutation semantics aligned with `docs/spec/COMMAND_CATALOG.md` + +Candidate command payloads include: + +- `change_to_pitch` +- `change_duration` +- `insert_note_after` +- `delete_note` +- `split_note` + +### 4. `state apply-command` + +Purpose: + +- apply one bounded mutation command to canonical `MusicXML` state and emit the next canonical `MusicXML` + +Input: + +- canonical `MusicXML` +- one machine-facing bounded command payload + +Behavior: + +- MUST reuse the same validation and command semantics as `state validate-command` +- MUST emit the next canonical `MusicXML` state on success +- MUST fail atomically when command execution fails +- MUST preserve the existing save/serialization policy defined in core specs + +### 5. `state diff` + +Purpose: + +- compare two canonical `MusicXML` states in a bounded, workflow-friendly way + +Input: + +- `--before` +- `--after` + +Behavior: + +- SHOULD produce a compact difference summary rather than a raw textual XML diff +- SHOULD focus on user-relevant score changes when practical +- MAY remain intentionally shallow in first cut if a deeper music-aware diff is not yet stable + +## Relationship To Core Command Catalog + +`state` SHOULD expose workflow phases, not one top-level CLI verb per mutation primitive. + +Preferred split: + +- CLI surface: + - `state summarize` + - `state inspect-measure` + - `state validate-command` + - `state apply-command` + - `state diff` +- payload layer: + - `change_to_pitch` + - `change_duration` + - `insert_note_after` + - `delete_note` + - `split_note` + +This preserves the current bounded edit semantics without making CLI command discovery noisy. + +## Small-edit Direction + +The first-cut `state` family is the natural home for the current "small edit" theme. + +That theme includes: + +- pitch change +- duration change +- note insertion +- note deletion +- note split +- likely future canonical-`MusicXML` light edits such as tempo-level changes + +This means: + +- "small edit feature" +- "`MusicXML`-centered light edit" +- "diff-based edit" + +should be treated as the same direction seen from different layers. + +## First-cut Non-Goals + +The first `state` family does not yet require: + +- patch envelopes containing many commands +- a broad AI-only command tree +- whole-measure rewrite as the primary mutation contract +- lyrics/melody alignment diagnostics +- batch orchestration inside the CLI itself + +## Relationship To Current Docs + +- `docs/spec/CLI_TAXONOMY_FIRSTCUT.md` defines the top-level future CLI split +- this file defines the strongest current candidate first cut inside `state` +- current implemented CLI behavior remains defined by `docs/spec/CLI_STEP1.md` diff --git a/docs/spec/CLI_STEP1.md b/docs/spec/CLI_STEP1.md index a0acf88..4b2b708 100644 --- a/docs/spec/CLI_STEP1.md +++ b/docs/spec/CLI_STEP1.md @@ -7,9 +7,15 @@ This document defines the first-cut CLI scope for `mikuscore`. Scope note: - This file defines only the initial CLI contract. +- Current implementation has already grown beyond this initial contract. - It does not define future AI JSON or patch-based workflows. - It does not replace the canonical MusicXML-centered architecture. +For current repository-facing behavior, also see: + +- `README.md` +- `docs/DEVELOPMENT.md` + ## Positioning The CLI is a thin external entrypoint for format conversion. @@ -238,3 +244,11 @@ If the CLI grows later, it SHOULD still preserve the same principles: - narrowly scoped command families Any later expansion beyond `ABC` SHOULD be justified by concrete workflow need, not by symmetry with another project. + +Current future-facing first-cut notes are tracked separately in: + +- `docs/spec/CLI_TAXONOMY_FIRSTCUT.md` +- `docs/spec/CLI_RENDER_FIRSTCUT.md` +- `docs/spec/CLI_STATE_FIRSTCUT.md` +- `docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md` +- `docs/spec/CLI_HELP_FIRSTCUT.md` diff --git a/docs/spec/CLI_TAXONOMY_FIRSTCUT.md b/docs/spec/CLI_TAXONOMY_FIRSTCUT.md new file mode 100644 index 0000000..df180b9 --- /dev/null +++ b/docs/spec/CLI_TAXONOMY_FIRSTCUT.md @@ -0,0 +1,156 @@ +# CLI Taxonomy First Cut + +## Purpose + +This document records the next candidate CLI taxonomy for `mikuscore`. + +Scope note: + +- this is a first-cut taxonomy and workflow note +- this is not yet the current implemented CLI contract +- detailed command payloads and diagnostics schemas may be defined later in separate specs + +## Positioning + +The current CLI is still centered on `convert --from ... --to ...`. + +The strongest next candidate is to organize the CLI around three top-level families: + +- `convert` +- `render` +- `state` + +This keeps `MusicXML` canonical while making command responsibilities clearer. + +## Top-level Families + +### 1. `convert` + +Purpose: + +- interchange between external score formats + +Examples: + +- `mikuscore convert --from abc --to musicxml` +- `mikuscore convert --from musicxml --to abc` +- `mikuscore convert --from midi --to musicxml` + +Rules: + +- `convert` SHOULD keep format-pair selection option-based via `--from` / `--to` +- `convert` SHOULD NOT expand into one fixed command per format pair +- `convert` SHOULD focus on interchange rather than canonical-state mutation + +### 2. `render` + +Purpose: + +- generate derived artifacts such as SVG + +Examples: + +- `mikuscore render svg --in score.musicxml` +- future one-shot example: `mikuscore render svg --from abc --in score.abc` + +Rules: + +- `render` SHOULD remain output-oriented +- internal canonical flow MAY still route through `MusicXML` +- a user-facing one-shot `ABC -> SVG` path is allowed even if the internal pipeline is `ABC -> MusicXML -> SVG` + +### 3. `state` + +Purpose: + +- inspect, validate, compare, and mutate canonical `MusicXML` state + +Candidate first-cut examples: + +- `mikuscore state summarize` +- `mikuscore state inspect-measure` +- `mikuscore state validate-command` +- `mikuscore state apply-command` +- `mikuscore state diff` + +Rules: + +- `state` SHOULD be the natural home for bounded edit-oriented workflows +- `state` SHOULD treat `MusicXML` as canonical state, not introduce a second canonical score model +- `state` SHOULD support both human CLI use and tool-mediated callers + +## Why This Taxonomy + +This split aims to satisfy several constraints at once: + +- preserve `MusicXML` as canonical +- keep format-pair growth manageable +- make user intent clearer at the top level +- give small edit workflows and diff-based mutation a natural home +- align better with Agent Skills and future tool-mediated AI workflows + +## Relationship To Existing Core Commands + +`mikuscore` already has a bounded internal command model: + +- `change_to_pitch` +- `change_duration` +- `insert_note_after` +- `delete_note` +- `split_note` + +Those command names describe mutation semantics well, but they do not need to become top-level CLI verbs. + +Preferred direction: + +- top-level CLI stays phase-oriented: `state inspect`, `state validate`, `state apply`, `state diff` +- machine-facing payloads inside those `state` commands SHOULD reuse the existing core command catalog + +This means `mikuscore` SHOULD avoid a surface such as: + +- `mikuscore change-to-pitch ...` +- `mikuscore change-duration ...` + +and instead prefer workflow-oriented commands with bounded command payloads. + +## Render Input Policy + +One open first-cut question is how much non-`MusicXML` input `render` should accept directly. + +Current strongest direction: + +- user-facing CLI SHOULD allow at least a direct `ABC -> SVG` path +- internal implementation SHOULD still route through canonical `MusicXML` + +This preserves the internal architecture while reducing user-visible friction. + +## Diagnostics Direction + +All three top-level families SHOULD converge on a shared diagnostics style. + +First-cut direction: + +- main artifact on `stdout` +- diagnostics on `stderr` +- human-readable text by default +- optional machine-readable form via `--diagnostics text|json` + +This direction is especially important for: + +- one-shot render flows that cross multiple internal stages +- `state` validation/apply workflows +- Agent Skills and other tool-mediated callers + +## First-cut Non-Goals + +This taxonomy note does not yet require: + +- batch conversion inside the CLI itself +- lyrics/melody alignment diagnostics +- a broad AI-specific command family +- a top-level command per internal edit primitive + +## Relationship To Current Docs + +- current implemented CLI behavior remains defined by `docs/spec/CLI_STEP1.md` +- this file defines the most plausible next taxonomy after the current first cut diff --git a/scripts/mikuscore-cli.mjs b/scripts/mikuscore-cli.mjs index 1ee35e3..ee05408 100644 --- a/scripts/mikuscore-cli.mjs +++ b/scripts/mikuscore-cli.mjs @@ -9,127 +9,194 @@ import { loadCliApi } from "./lib/load-cli-api.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); +const DIAGNOSTICS_VERSION = 1; + +const HELP_TEXT = { + top: [ + "Usage:", + " mikuscore convert --from abc --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musicxml --to abc [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from midi --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musicxml --to midi [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musescore --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musicxml --to musescore [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore render svg [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore state summarize [--in |-] [--diagnostics text|json]", + " mikuscore state inspect-measure --measure [--in |-] [--diagnostics text|json]", + " mikuscore state validate-command [--in |-] [--command |--command-file |-] [--diagnostics text|json]", + " mikuscore state apply-command [--in |-] [--command |--command-file |-] [--out |-] [--diagnostics text|json]", + " mikuscore state diff --before --after [--diagnostics text|json]", + " mikuscore render --help", + " mikuscore state --help", + " mikuscore convert --help", + " mikuscore --help", + "", + "Commands:", + " convert Convert score text between supported formats", + " render Render derived outputs such as SVG", + " state Inspect canonical MusicXML state", + "", + "Options:", + " --from Source format", + " --to Target format", + " --in |- Read input from file or stdin", + " --out |- Write output to file or stdout", + " --diagnostics text|json Select diagnostics format", + " --help Show help", + ].join("\n"), + convert: [ + "Usage:", + " mikuscore convert --from abc --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musicxml --to abc [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --help", + "", + "Description:", + " Convert score text between supported formats.", + "", + "Supported pairs:", + " --from abc --to musicxml", + " --from musicxml --to abc", + " --from midi --to musicxml", + " --from musicxml --to midi", + " --from musescore --to musicxml", + " --from musicxml --to musescore", + "", + "Input:", + " --in |- Read source text or bytes from file or stdin", + " stdin Used when --in is omitted", + " file paths musicxml accepts .musicxml / .xml / .mxl; musescore accepts .mscx / .mscz", + "", + "Output:", + " --out |- Write converted text or bytes to file or stdout", + " stdout Used when --out is omitted", + " file paths --to musicxml writes .mxl when --out ends with .mxl; --to musescore writes .mscz when --out ends with .mscz", + "", + "Options:", + " --from Source format", + " --to Target format", + " --diagnostics text|json Select diagnostics format", + " --help Show help", + ].join("\n"), + render: [ + "Usage:", + " mikuscore render svg [--from ] [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore render --help", + "", + "Description:", + " Render derived outputs from canonical MusicXML input or supported one-shot source formats.", + "", + "Available targets:", + " svg", + "", + "Input:", + " --from Source format for render input (default: musicxml)", + " --in |- Read render input from file or stdin", + " stdin Used when --in is omitted", + "", + "Output:", + " --out |- Write rendered output to file or stdout", + " stdout Used when --out is omitted", + "", + "Options:", + " --from Source format", + " --diagnostics text|json Select diagnostics format", + " --help Show help", + ].join("\n"), + state: [ + "Usage:", + " mikuscore state summarize [--in |-] [--diagnostics text|json]", + " mikuscore state inspect-measure --measure [--in |-] [--diagnostics text|json]", + " mikuscore state validate-command [--in |-] [--command |--command-file |-] [--diagnostics text|json]", + " mikuscore state apply-command [--in |-] [--command |--command-file |-] [--out |-] [--diagnostics text|json]", + " mikuscore state diff --before --after [--diagnostics text|json]", + " mikuscore state --help", + "", + "Description:", + " Inspect canonical MusicXML state.", + "", + "Available commands:", + " summarize Emit a compact JSON summary of canonical MusicXML state", + " inspect-measure Emit a compact JSON view of one measure for edit targeting", + " validate-command Validate one bounded command against canonical MusicXML state", + " apply-command Apply one bounded command and emit the next canonical MusicXML state", + " diff Emit a compact JSON summary of differences between two canonical MusicXML states", + "", + "Options:", + " --diagnostics text|json Select diagnostics format", + " --help Show help", + ].join("\n"), +}; + +class CliUsageError extends Error { + constructor(message, code = "usage_error", details = undefined) { + super(message); + this.name = "CliUsageError"; + this.code = code; + this.details = details; + } +} -const TOP_LEVEL_HELP = `Usage: - mikuscore convert --from abc --to musicxml [--in ] [--out ] - mikuscore convert --from musicxml --to abc [--in ] [--out ] - mikuscore convert --from midi --to musicxml [--in ] [--out ] - mikuscore convert --from musicxml --to midi [--in ] [--out ] - mikuscore convert --from musescore --to musicxml [--in ] [--out ] - mikuscore convert --from musicxml --to musescore [--in ] [--out ] - mikuscore render svg [--in ] [--out ] - mikuscore render --help - mikuscore convert --help - mikuscore --help - -Commands: - convert Convert score text between supported formats - render Render derived outputs such as SVG - -Options: - --from Source format - --to Target format - --in Read input from file instead of stdin - --out Write output to file instead of stdout - --help Show help -`; - -const CONVERT_HELP = `Usage: - mikuscore convert --from abc --to musicxml [--in ] [--out ] - mikuscore convert --from musicxml --to abc [--in ] [--out ] - mikuscore convert --help - -Description: - Convert score text between supported formats. - -Supported pairs: - --from abc --to musicxml - --from musicxml --to abc - --from midi --to musicxml - --from musicxml --to midi - --from musescore --to musicxml - --from musicxml --to musescore - -Input: - --in Read source text from file - stdin Used when --in is omitted - file paths musicxml accepts .musicxml / .xml / .mxl; musescore accepts .mscx / .mscz - -Output: - --out Write converted text to file - stdout Used when --out is omitted - file paths --to musicxml writes .mxl when --out ends with .mxl; --to musescore writes .mscz when --out ends with .mscz - -Options: - --from Source format - --to Target format - --help Show help -`; - -const RENDER_HELP = `Usage: - mikuscore render svg [--in ] [--out ] - mikuscore render --help - -Description: - Render derived outputs from canonical MusicXML input. - -Available targets: - svg - -Input: - --in Read MusicXML text from file - stdin Used when --in is omitted - -Output: - --out Write rendered output to file - stdout Used when --out is omitted - -Options: - --help Show help -`; +class CliProcessingError extends Error { + constructor(message, code = "processing_error", details = undefined) { + super(message); + this.name = "CliProcessingError"; + this.code = code; + this.details = details; + } +} class CliCommandFailure extends Error { constructor(result, fallbackMessage) { super(result.diagnostics[0] || fallbackMessage); + this.name = "CliCommandFailure"; this.result = result; } } main().catch((error) => { - if (error instanceof CliCommandFailure) { + const rawArgv = process.argv.slice(2); + const diagnosticsFormat = detectRequestedDiagnosticsFormat(rawArgv); + const exitCode = error instanceof CliUsageError ? 2 : 1; + if (diagnosticsFormat === "json") { + process.stderr.write(`${JSON.stringify(buildErrorDiagnostics(rawArgv, error, exitCode), null, 2)}\n`); + } else if (error instanceof CliCommandFailure) { writeMessages(process.stderr, error.result.warnings, error.result.diagnostics); if (!error.result.diagnostics.length && error.message) { process.stderr.write(`${error.message}\n`); } - process.exitCode = 1; - return; + } else { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); } - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; + process.exit(exitCode); }); async function main() { const { command, options } = parseArgs(process.argv.slice(2)); if (command.length === 0 || (options.help && !options.helpCommand)) { - process.stdout.write(TOP_LEVEL_HELP); + writeHelp(process.stdout, "top"); return; } if (isCommand(command, ["convert"]) && options.helpCommand) { - process.stdout.write(CONVERT_HELP); + writeHelp(process.stdout, "convert"); return; } if (isCommand(command, ["render"]) && options.helpCommand) { - process.stdout.write(RENDER_HELP); + writeHelp(process.stdout, "render"); + return; + } + + if (isCommand(command, ["state"]) && options.helpCommand) { + writeHelp(process.stdout, "state"); return; } const loaded = loadCliApi({ rootDir: repoRoot }); try { const result = await runCommand(command, options, loaded.api); - writeMessages(process.stderr, result.warnings, result.diagnostics); + writeDiagnostics(process.stderr, buildSuccessDiagnostics(command, options, result), options.diagnostics); writeOutput(result.output, options.out); } finally { loaded.dispose(); @@ -153,9 +220,24 @@ function parseArgs(argv) { continue; } + if (key === "diagnostics") { + const diagnosticsValue = argv[index + 1]; + if (!diagnosticsValue || diagnosticsValue.startsWith("--")) { + throw new CliUsageError(`Option ${token} requires a value.`, "missing_option_value", { option: token }); + } + if (diagnosticsValue !== "text" && diagnosticsValue !== "json") { + throw new CliUsageError("--diagnostics must be either text or json.", "invalid_diagnostics_option", { + option: "--diagnostics", + }); + } + options.diagnostics = diagnosticsValue; + index += 1; + continue; + } + const value = argv[index + 1]; if (value === undefined || value.startsWith("--")) { - throw new Error(`Option ${token} requires a value.`); + throw new CliUsageError(`Option ${token} requires a value.`, "missing_option_value", { option: token }); } options[key] = value; index += 1; @@ -178,7 +260,7 @@ async function runCommand(command, options, api) { const to = String(options.to || "").trim().toLowerCase(); if (!from || !to) { - throw new Error("convert requires both --from and --to ."); + throw new CliUsageError("convert requires both --from and --to .", "missing_from_to"); } if (from === "abc" && to === "musicxml") { @@ -254,10 +336,38 @@ async function runCommand(command, options, api) { return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; } - throw new Error(`Unsupported conversion pair: --from ${from} --to ${to}`); + throw new CliUsageError(`Unsupported conversion pair: --from ${from} --to ${to}`, "unsupported_conversion_pair", { + from, + to, + }); } if (isCommand(command, ["render", "svg"])) { + const from = String(options.from || "musicxml").trim().toLowerCase(); + + if (from === "abc") { + const inputText = await readTextInput(options.in); + const imported = api.abc.importToMusicXml(inputText); + if (!imported.ok || typeof imported.output !== "string") { + throw new CliCommandFailure(imported, "ABC to MusicXML conversion failed."); + } + const rendered = await api.render.svgFromMusicXml(imported.output); + if (!rendered.ok) { + throw new CliCommandFailure(rendered, "SVG render failed."); + } + return { + ...rendered, + warnings: [...imported.warnings, ...rendered.warnings], + diagnostics: [...imported.diagnostics, ...rendered.diagnostics], + }; + } + + if (from !== "musicxml") { + throw new CliUsageError(`Unsupported render source: --from ${from}`, "unsupported_render_source", { + from, + }); + } + const inputBytes = await readBinaryInput(options.in); const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); if (!decoded.ok || typeof decoded.output !== "string") { @@ -271,7 +381,86 @@ async function runCommand(command, options, api) { return result; } - throw new Error(`Unsupported command: ${command.join(" ")}`); + if (isCommand(command, ["state", "summarize"])) { + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + const result = api.state.summarizeFromMusicXml(decoded.output); + if (!result.ok) { + throw new CliCommandFailure(result, "Failed to summarize MusicXML state."); + } + return result; + } + + if (isCommand(command, ["state", "inspect-measure"])) { + const measure = String(options.measure || "").trim(); + if (!measure) { + throw new CliUsageError("state inspect-measure requires --measure .", "missing_measure_option"); + } + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + const result = api.state.inspectMeasureFromMusicXml(decoded.output, measure); + if (!result.ok) { + throw new CliCommandFailure(result, "Failed to inspect MusicXML measure."); + } + return result; + } + + if (isCommand(command, ["state", "validate-command"])) { + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + const commandPayload = await readCommandPayload(options); + const result = api.state.validateCommandFromMusicXml(decoded.output, commandPayload); + if (!result.ok) { + throw new CliCommandFailure(result, "Failed to validate MusicXML command."); + } + return result; + } + + if (isCommand(command, ["state", "apply-command"])) { + const inputBytes = await readBinaryInput(options.in); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + const commandPayload = await readCommandPayload(options); + const result = api.state.applyCommandFromMusicXml(decoded.output, commandPayload); + if (!result.ok) { + throw new CliCommandFailure(result, "Failed to apply MusicXML command."); + } + return result; + } + + if (isCommand(command, ["state", "diff"])) { + if (!options.before || !options.after) { + throw new CliUsageError("state diff requires both --before and --after .", "missing_diff_inputs"); + } + const beforeBytes = await readBinaryInput(options.before); + const afterBytes = await readBinaryInput(options.after); + const beforeDecoded = await api.fileIO.musicxml.decodeInput(beforeBytes, options.before); + const afterDecoded = await api.fileIO.musicxml.decodeInput(afterBytes, options.after); + if (!beforeDecoded.ok || typeof beforeDecoded.output !== "string") { + throw new CliCommandFailure(beforeDecoded, "Failed to read before MusicXML input."); + } + if (!afterDecoded.ok || typeof afterDecoded.output !== "string") { + throw new CliCommandFailure(afterDecoded, "Failed to read after MusicXML input."); + } + const result = api.state.diffMusicXmlState(beforeDecoded.output, afterDecoded.output); + if (!result.ok) { + throw new CliCommandFailure(result, "Failed to diff MusicXML state."); + } + return result; + } + + throw new CliUsageError(`Unsupported command: ${command.join(" ")}`, "unsupported_command"); } async function encodeOutputForTarget(result, outPath, api, to) { @@ -291,7 +480,7 @@ async function readTextInput(inputPath) { } async function readBinaryInput(inputPath) { - if (inputPath) { + if (inputPath && inputPath !== "-") { return fs.readFileSync(path.resolve(inputPath)); } @@ -300,7 +489,7 @@ async function readBinaryInput(inputPath) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); } if (chunks.length === 0) { - throw new Error("Input is required. Use --in or pipe text via stdin."); + throw new CliUsageError("Input is required. Use --in or pipe text via stdin.", "missing_input"); } return Buffer.concat(chunks); } @@ -314,11 +503,150 @@ function writeMessages(stream, warnings = [], diagnostics = []) { } } +function writeDiagnostics(stream, diagnostics, diagnosticsFormat = "text") { + if (diagnosticsFormat === "json") { + stream.write(`${JSON.stringify(diagnostics, null, 2)}\n`); + return; + } + writeMessages(stream, diagnostics.warnings, diagnostics.errors); +} + function writeOutput(output, outPath) { const payload = typeof output === "string" ? output : Buffer.from(output); - if (outPath) { + if (outPath && outPath !== "-") { fs.writeFileSync(path.resolve(outPath), payload); return; } process.stdout.write(payload); } + +async function readCommandPayload(options) { + const hasInline = typeof options.command === "string"; + const hasFile = typeof options["command-file"] === "string"; + if (hasInline === hasFile) { + throw new CliUsageError( + "state validate-command requires exactly one of --command or --command-file .", + "missing_command_payload" + ); + } + + const jsonText = hasInline ? options.command : await readTextInput(options["command-file"]); + try { + return JSON.parse(jsonText); + } catch (error) { + throw new CliUsageError( + `Command payload must be valid JSON: ${error instanceof Error ? error.message : String(error)}`, + "invalid_command_json" + ); + } +} + +function writeHelp(stream, topic) { + stream.write(`${HELP_TEXT[topic]}\n`); +} + +function detectRequestedDiagnosticsFormat(argv) { + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === "--diagnostics" && argv[index + 1] === "json") { + return "json"; + } + } + return "text"; +} + +function buildSuccessDiagnostics(command, options, result) { + const warnings = Array.isArray(result.warnings) ? result.warnings : []; + const errors = Array.isArray(result.diagnostics) ? result.diagnostics : []; + const status = errors.length > 0 ? "error" : warnings.length > 0 ? "warning" : "success"; + return { + ok: result.ok && errors.length === 0, + diagnostics_version: DIAGNOSTICS_VERSION, + command: command.join(" "), + context: command.join(" "), + status, + exit_code: status === "error" ? 1 : 0, + warning_count: warnings.length, + error_count: errors.length, + io: buildIoDiagnostics(options), + warnings, + errors, + }; +} + +function buildErrorDiagnostics(argv, error, exitCode) { + const command = summarizeCommandFromArgv(argv); + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + diagnostics_version: DIAGNOSTICS_VERSION, + command, + context: command, + status: "error", + exit_code: exitCode, + warning_count: 0, + error_count: 1, + io: buildIoDiagnosticsFromArgv(argv), + error_type: error instanceof CliUsageError ? "usage_error" : "processing_error", + error_code: typeof error?.code === "string" ? error.code : "processing_error", + error_details: error?.details, + warnings: [], + errors: [message], + }; +} + +function summarizeCommandFromArgv(argv) { + const command = []; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + command.push(token); + continue; + } + if (token !== "--help") { + index += 1; + } + } + return command.join(" ") || "cli"; +} + +function buildIoDiagnostics(options) { + return { + inputs: buildInputListFromOptions(options), + output: buildOutputFromValue(options.out), + }; +} + +function buildIoDiagnosticsFromArgv(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) continue; + const key = token.slice(2); + if (key === "help") continue; + options[key] = argv[index + 1]; + index += 1; + } + return buildIoDiagnostics(options); +} + +function buildInputListFromOptions(options) { + const inputs = []; + if ("in" in options) { + inputs.push(buildInputDescriptor("--in", options.in)); + } + return inputs.length > 0 ? inputs : [{ option: "--in", mode: "stdin" }]; +} + +function buildInputDescriptor(option, value) { + if (value === "-" || value === undefined) { + return { option, mode: "stdin" }; + } + return { option, mode: "file", path: value }; +} + +function buildOutputFromValue(value) { + if (value === "-" || value === undefined) { + return { mode: "stdout" }; + } + return { mode: "file", path: value }; +} diff --git a/src/ts/cli-api.ts b/src/ts/cli-api.ts index 0c0457b..a2940e7 100644 --- a/src/ts/cli-api.ts +++ b/src/ts/cli-api.ts @@ -26,6 +26,9 @@ import { makeMsczBytes, makeMxlBytes, } from "./zip-io"; +import { ScoreCore } from "../../core/ScoreCore"; +import type { CoreCommand } from "../../core/interfaces"; +import { getDurationValue, getVoiceText, parseXml as parseCoreXml, reindexNodeIds } from "../../core/xmlUtils"; export type CliResult = | { @@ -324,6 +327,272 @@ export const renderMusicXmlToSvg = async (xmlText: string): Promise = } }; +export const summarizeMusicXmlState = (xmlText: string): CliResult => { + const doc = parseMusicXmlDocument(xmlText); + if (!doc) { + return { + ok: false, + warnings: [], + diagnostics: ["Failed to parse MusicXML: input is not a valid MusicXML document."], + }; + } + + try { + const parts = Array.from(doc.querySelectorAll("score-partwise > part")); + const measures = Array.from(doc.querySelectorAll("score-partwise > part > measure")); + const measureNumbers = Array.from( + new Set( + measures + .map((measure) => measure.getAttribute("number")?.trim() ?? "") + .filter((value) => value.length > 0) + ) + ); + const voices = Array.from( + new Set( + Array.from(doc.querySelectorAll("score-partwise > part > measure > note > voice")) + .map((voice) => voice.textContent?.trim() ?? "") + .filter((value) => value.length > 0) + ) + ); + const summary = { + kind: "musicxml_state_summary", + title: + doc.querySelector("score-partwise > work > work-title")?.textContent?.trim() ?? + doc.querySelector("score-partwise > movement-title")?.textContent?.trim() ?? + null, + part_count: parts.length, + measure_count: measures.length, + measure_numbers: measureNumbers, + voices, + }; + return { + ok: true, + output: `${JSON.stringify(summary, null, 2)}\n`, + warnings: [], + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to summarize MusicXML state: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + +export const validateMusicXmlCommand = (xmlText: string, command: CoreCommand): CliResult => { + try { + const core = new ScoreCore(); + core.load(xmlText); + const result = core.dispatch(command); + return { + ok: true, + output: `${JSON.stringify( + { + kind: "musicxml_command_validation", + ok: result.ok, + dirty_changed: result.dirtyChanged, + changed_node_ids: result.changedNodeIds, + affected_measure_numbers: result.affectedMeasureNumbers, + warnings: result.warnings, + diagnostics: result.diagnostics, + }, + null, + 2 + )}\n`, + warnings: [], + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to validate MusicXML command: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + +export const applyMusicXmlCommand = (xmlText: string, command: CoreCommand): CliResult => { + try { + const core = new ScoreCore(); + core.load(xmlText); + const result = core.dispatch(command); + if (!result.ok) { + return { + ok: true, + output: `${JSON.stringify( + { + kind: "musicxml_command_apply", + ok: false, + changed_node_ids: result.changedNodeIds, + affected_measure_numbers: result.affectedMeasureNumbers, + warnings: result.warnings, + diagnostics: result.diagnostics, + }, + null, + 2 + )}\n`, + warnings: [], + diagnostics: [], + }; + } + + const saved = core.save(); + if (!saved.ok) { + return { + ok: false, + warnings: [], + diagnostics: saved.diagnostics.map((item) => item.message), + }; + } + + return { + ok: true, + output: saved.xml, + warnings: result.warnings.map((item) => item.message), + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to apply MusicXML command: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + +export const inspectMusicXmlMeasure = (xmlText: string, measureNumber: string): CliResult => { + try { + const doc = parseCoreXml(xmlText); + const nodeToId = new WeakMap(); + const idToNode = new Map(); + let sequence = 0; + reindexNodeIds(doc, nodeToId, idToNode, () => { + sequence += 1; + return `n${sequence}`; + }); + + const measures = Array.from(doc.querySelectorAll("score-partwise > part > measure")) + .filter((measure) => (measure.getAttribute("number")?.trim() ?? "") === measureNumber); + + const summary = { + kind: "musicxml_measure_inspection", + measure_number: measureNumber, + measures: measures.map((measure) => { + const part = measure.parentElement; + const partId = part?.getAttribute("id")?.trim() ?? null; + const voiceNoteCounts = new Map(); + const notes = Array.from(measure.querySelectorAll(":scope > note")).map((note, noteIndex) => { + const nodeId = nodeToId.get(note) ?? null; + const voice = getVoiceText(note); + const step = note.querySelector(":scope > pitch > step")?.textContent?.trim() ?? null; + const octaveText = note.querySelector(":scope > pitch > octave")?.textContent?.trim() ?? null; + const alterText = note.querySelector(":scope > pitch > alter")?.textContent?.trim() ?? null; + const alter = alterText === null ? null : Number(alterText); + const voiceKey = voice ?? "__none__"; + const nextVoiceNoteIndex = (voiceNoteCounts.get(voiceKey) ?? 0) + 1; + voiceNoteCounts.set(voiceKey, nextVoiceNoteIndex); + return { + node_id: nodeId, + selector: { + part_id: partId, + measure_number: measureNumber, + measure_note_index: noteIndex + 1, + voice, + voice_note_index: nextVoiceNoteIndex, + }, + voice, + duration: getDurationValue(note), + is_rest: note.querySelector(":scope > rest") !== null, + pitch: step && octaveText + ? { + step, + alter: Number.isFinite(alter) ? alter : null, + octave: Number(octaveText), + } + : null, + }; + }); + return { + part_id: partId, + note_count: notes.length, + notes, + }; + }), + }; + + return { + ok: true, + output: `${JSON.stringify(summary, null, 2)}\n`, + warnings: [], + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to inspect MusicXML measure: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + +const buildMusicXmlStateSummaryObject = (doc: Document) => { + const parts = Array.from(doc.querySelectorAll("score-partwise > part")); + const measures = Array.from(doc.querySelectorAll("score-partwise > part > measure")); + const notes = Array.from(doc.querySelectorAll("score-partwise > part > measure > note")); + return { + title: + doc.querySelector("score-partwise > work > work-title")?.textContent?.trim() ?? + doc.querySelector("score-partwise > movement-title")?.textContent?.trim() ?? + null, + part_count: parts.length, + measure_count: measures.length, + note_count: notes.length, + measure_numbers: Array.from( + new Set( + measures + .map((measure) => measure.getAttribute("number")?.trim() ?? "") + .filter((value) => value.length > 0) + ) + ), + }; +}; + +export const diffMusicXmlState = (beforeXml: string, afterXml: string): CliResult => { + try { + const beforeDoc = parseCoreXml(beforeXml); + const afterDoc = parseCoreXml(afterXml); + const beforeSummary = buildMusicXmlStateSummaryObject(beforeDoc); + const afterSummary = buildMusicXmlStateSummaryObject(afterDoc); + + const changedFields = Object.keys(beforeSummary).filter((key) => { + return JSON.stringify(beforeSummary[key as keyof typeof beforeSummary]) !== + JSON.stringify(afterSummary[key as keyof typeof afterSummary]); + }); + + const diff = { + kind: "musicxml_state_diff", + changed: changedFields.length > 0, + changed_fields: changedFields, + before: beforeSummary, + after: afterSummary, + }; + + return { + ok: true, + output: `${JSON.stringify(diff, null, 2)}\n`, + warnings: [], + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to diff MusicXML state: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + export const cliApi = { abc: { importToMusicXml: importAbcToMusicXml, @@ -350,4 +619,11 @@ export const cliApi = { render: { svgFromMusicXml: renderMusicXmlToSvg, }, + state: { + summarizeFromMusicXml: summarizeMusicXmlState, + inspectMeasureFromMusicXml: inspectMusicXmlMeasure, + validateCommandFromMusicXml: validateMusicXmlCommand, + applyCommandFromMusicXml: applyMusicXmlCommand, + diffMusicXmlState, + }, }; diff --git a/tests/unit/mikuscore-cli.spec.ts b/tests/unit/mikuscore-cli.spec.ts index 8e47d79..46dfd88 100644 --- a/tests/unit/mikuscore-cli.spec.ts +++ b/tests/unit/mikuscore-cli.spec.ts @@ -30,12 +30,14 @@ describe("mikuscore cli", () => { const topLevel = runCli(["--help"]); const convertHelp = runCli(["convert", "--help"]); const renderHelp = runCli(["render", "--help"]); + const stateHelp = runCli(["state", "--help"]); expect(topLevel.status).toBe(0); expect(topLevel.stdout).toContain("mikuscore convert --from abc --to musicxml"); expect(topLevel.stdout).toContain("mikuscore convert --from midi --to musicxml"); expect(topLevel.stdout).toContain("mikuscore convert --from musescore --to musicxml"); expect(topLevel.stdout).toContain("mikuscore render svg"); + expect(topLevel.stdout).toContain("mikuscore state summarize"); expect(topLevel.stderr).toBe(""); expect(convertHelp.status).toBe(0); @@ -43,8 +45,18 @@ describe("mikuscore cli", () => { expect(convertHelp.stderr).toBe(""); expect(renderHelp.status).toBe(0); - expect(renderHelp.stdout).toContain("Render derived outputs from canonical MusicXML input"); + expect(renderHelp.stdout).toContain("supported one-shot source formats"); + expect(renderHelp.stdout).toContain("--from "); expect(renderHelp.stderr).toBe(""); + + expect(stateHelp.status).toBe(0); + expect(stateHelp.stdout).toContain("Inspect canonical MusicXML state"); + expect(stateHelp.stdout).toContain("summarize"); + expect(stateHelp.stdout).toContain("inspect-measure"); + expect(stateHelp.stdout).toContain("validate-command"); + expect(stateHelp.stdout).toContain("apply-command"); + expect(stateHelp.stdout).toContain("diff"); + expect(stateHelp.stderr).toBe(""); }, 10000); it("converts stdin to stdout for a supported pair", () => { @@ -67,6 +79,15 @@ describe("mikuscore cli", () => { expect(readFileSync(outPath, "utf8")).toContain(" { + const inputPath = writeTempFile("score.abc", "X:1\nT:Stdout dash\nM:4/4\nL:1/4\nK:C\nC D E F|\n"); + + const result = runCli(["convert", "--from", "abc", "--to", "musicxml", "--in", inputPath, "--out", "-"]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Stdout dash"); + }); + it("reads .mxl input files for musicxml source", () => { const inputPath = path.resolve(repoRoot, "src", "samples", "musicxml", "sample1.mxl"); const result = runCli(["convert", "--from", "musicxml", "--to", "abc", "--in", inputPath]); @@ -123,6 +144,147 @@ describe("mikuscore cli", () => { expect(result.stdout).toContain(" { + const inputPath = writeTempFile("score.abc", "X:1\nT:Render ABC\nM:4/4\nL:1/4\nK:C\nC D E F|\n"); + + const result = runCli(["render", "svg", "--from", "abc", "--in", inputPath]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(" { + const result = runCli(["state", "summarize"], { + input: validMusicXml("State summary"), + }); + + expect(result.status).toBe(0); + const summary = JSON.parse(result.stdout); + expect(summary.kind).toBe("musicxml_state_summary"); + expect(summary.title).toBe("State summary"); + expect(summary.part_count).toBe(1); + expect(summary.measure_count).toBe(1); + expect(summary.measure_numbers).toEqual(["1"]); + expect(summary.voices).toEqual([]); + }); + + it("validates one bounded MusicXML command", () => { + const result = runCli( + [ + "state", + "validate-command", + "--command", + JSON.stringify({ + type: "change_to_pitch", + targetNodeId: "n1", + voice: "1", + pitch: { step: "G", octave: 4 }, + }), + ], + { + input: validEditableMusicXml("Validate command"), + } + ); + + expect(result.status).toBe(0); + const validation = JSON.parse(result.stdout); + expect(validation.kind).toBe("musicxml_command_validation"); + expect(validation.ok).toBe(true); + expect(validation.changed_node_ids).toEqual(["n1"]); + expect(validation.affected_measure_numbers).toEqual(["1"]); + }); + + it("inspects one measure for edit targeting", () => { + const result = runCli(["state", "inspect-measure", "--measure", "1"], { + input: validEditableMusicXml("Inspect measure"), + }); + + expect(result.status).toBe(0); + const inspected = JSON.parse(result.stdout); + expect(inspected.kind).toBe("musicxml_measure_inspection"); + expect(inspected.measure_number).toBe("1"); + expect(inspected.measures).toHaveLength(1); + expect(inspected.measures[0].part_id).toBe("P1"); + expect(inspected.measures[0].note_count).toBe(4); + expect(inspected.measures[0].notes[0].node_id).toBe("n1"); + expect(inspected.measures[0].notes[0].selector).toEqual({ + part_id: "P1", + measure_number: "1", + measure_note_index: 1, + voice: "1", + voice_note_index: 1, + }); + expect(inspected.measures[0].notes[0].pitch.step).toBe("C"); + }); + + it("applies one bounded MusicXML command", () => { + const result = runCli( + [ + "state", + "apply-command", + "--command", + JSON.stringify({ + type: "change_to_pitch", + targetNodeId: "n1", + voice: "1", + pitch: { step: "G", octave: 4 }, + }), + ], + { + input: validEditableMusicXml("Apply command"), + } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("G"); + expect(result.stdout).toContain("4"); + }); + + it("diffs two canonical MusicXML states", () => { + const beforePath = writeTempFile("before.musicxml", validEditableMusicXml("Before title")); + const afterPath = writeTempFile("after.musicxml", validEditableMusicXml("After title").replace("C", "G")); + + const result = runCli(["state", "diff", "--before", beforePath, "--after", afterPath]); + + expect(result.status).toBe(0); + const diff = JSON.parse(result.stdout); + expect(diff.kind).toBe("musicxml_state_diff"); + expect(diff.changed).toBe(true); + expect(diff.changed_fields).toContain("title"); + expect(diff.before.title).toBe("Before title"); + expect(diff.after.title).toBe("After title"); + }); + + it("writes structured diagnostics as json when requested", () => { + const result = runCli(["render", "svg", "--diagnostics", "json"], { + input: validMusicXml("SVG diagnostics"), + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(" { + const result = runCli(["convert", "--from", "abc", "--diagnostics", "json"], { + input: "X:1\nT:Bad\nM:4/4\nL:1/4\nK:C\nC D E F|\n", + }); + + expect(result.status).toBe(2); + const diagnostics = JSON.parse(result.stderr); + expect(diagnostics.ok).toBe(false); + expect(diagnostics.error_type).toBe("usage_error"); + expect(diagnostics.error_code).toBe("missing_from_to"); + expect(diagnostics.exit_code).toBe(2); + }); + it("reports expected CLI failures", () => { const missingInput = runCli(["convert", "--from", "abc", "--to", "musicxml"]); const inputPath = writeTempFile("invalid.abc", ""); @@ -135,17 +297,35 @@ describe("mikuscore cli", () => { const missingFromTo = runCli(["convert", "--from", "abc"], { input: "X:1\nT:Bad\nM:4/4\nL:1/4\nK:C\nC D E F|\n", }); + const unsupportedRenderSource = runCli(["render", "svg", "--from", "midi"], { + input: "unused", + }); + const missingCommandPayload = runCli(["state", "validate-command"], { + input: validEditableMusicXml("Missing command"), + }); + const missingMeasureOption = runCli(["state", "inspect-measure"], { + input: validEditableMusicXml("Missing measure"), + }); + const missingDiffInputs = runCli(["state", "diff"]); - expect(missingInput.status).toBe(1); + expect(missingInput.status).toBe(2); expect(missingInput.stderr).toContain("Input is required"); expect(invalidAbc.status).toBe(1); expect(invalidAbc.stderr).toContain("Failed to parse ABC"); expect(invalidMusicXml.status).toBe(1); expect(invalidMusicXml.stderr).toContain("Failed to parse MusicXML"); - expect(unsupportedPair.status).toBe(1); + expect(unsupportedPair.status).toBe(2); expect(unsupportedPair.stderr).toContain("Unsupported conversion pair"); - expect(missingFromTo.status).toBe(1); + expect(missingFromTo.status).toBe(2); expect(missingFromTo.stderr).toContain("convert requires both --from and --to "); + expect(unsupportedRenderSource.status).toBe(2); + expect(unsupportedRenderSource.stderr).toContain("Unsupported render source"); + expect(missingCommandPayload.status).toBe(2); + expect(missingCommandPayload.stderr).toContain("requires exactly one of --command"); + expect(missingMeasureOption.status).toBe(2); + expect(missingMeasureOption.stderr).toContain("requires --measure"); + expect(missingDiffInputs.status).toBe(2); + expect(missingDiffInputs.stderr).toContain("requires both --before"); }, 15000); }); @@ -202,3 +382,27 @@ function validMusicXml(title: string) { `; } + +function validEditableMusicXml(title: string) { + return ` + + ${title} + + Music + + + + + 1 + 0 + + G2 + + C411quarter + D411quarter + E411quarter + F411quarter + + +`; +} From 9d9122666b3537e36b88718270c2bcc5bf27b6a2 Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Fri, 17 Apr 2026 22:38:25 +0900 Subject: [PATCH 4/8] =?UTF-8?q?CLI=20=E5=86=8D=E6=A7=8B=E7=AF=89=E3=82=92?= =?UTF-8?q?=E5=89=8D=E9=80=B2=E3=81=97=E3=80=81`state`=20selector=20target?= =?UTF-8?q?ing=20=E3=81=A8=20stage-aware=20diagnostics=20=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 CLI 再構築の続きを進め、`state` 系コマンドの targeting を強化しました。 あわせて、one-shot `render svg --from abc` の JSON diagnostics に stage 情報を追加し、CLI help / spec / README / development note を現在の実装に合わせて更新しています。 ## 変更内容 ### `state` 系コマンドの targeting 強化 `src/ts/cli-api.ts` で、`state validate-command` / `state apply-command` の command payload が以下の両方を受けられるようになりました。 - `targetNodeId` / `anchorNodeId` の直接指定 - `state inspect-measure` 出力に基づく `selector` / `anchor_selector` これにより、`inspect-measure` の結果をそのまま次の bounded edit workflow へつなぎやすくしています。 ### `state inspect-measure` / `state diff` の実用性向上 `src/ts/cli-api.ts` で以下を強化しました。 - `state inspect-measure` - hybrid targeting hint を返す方向を維持 - `state diff` - `changed_measure_numbers` - `changed_measures` を返すようにし、raw XML diff ではなく workflow-friendly な差分 summary を少し深めました。 ### one-shot render の stage-aware diagnostics `scripts/mikuscore-cli.mjs` で、`render svg --from abc --diagnostics json` 実行時に `stages` を返すようにしました。 現状の stage は以下です。 - `abc_to_musicxml` - `musicxml_to_svg` primary artifact は従来どおり `stdout` の SVG のままにしつつ、`stderr` の JSON diagnostics だけを強化しています。 ### CLI 実装の整理 `scripts/mikuscore-cli.mjs` では、`MusicXML` 入力の読み込みや `state` 実行の重複を helper 化して整理しています。 - `readMusicXmlInputText(...)` - `runStateMusicXmlCommand(...)` ### ドキュメント更新 以下を current behavior に合わせて更新しました。 - `README.md` - `docs/DEVELOPMENT.md` - `docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md` - `docs/spec/CLI_HELP_FIRSTCUT.md` - `docs/spec/CLI_STATE_FIRSTCUT.md` - `TODO.md` 主に反映した内容は以下です。 - `state` payload が `selector` / `anchor_selector` を受けられること - one-shot render の JSON diagnostics が `stages` を返すこと - CLI 再構築系列の TODO 実態反映 ## テスト `tests/unit/mikuscore-cli.spec.ts` を拡張し、以下を追加・更新しました。 - `state validate-command` の selector targeting - `state apply-command` の selector targeting - `insert_note_after` の `anchor_selector` targeting - `state diff` の measure-level summary - one-shot render の stage-aware diagnostics - `state --help` の payload note ## 補足 - `index.html` の更新日は `2026-04-17` に更新されています - `推測:` このコミットは CLI 再構築の first cut をさらに実運用しやすくするための refinement と位置づけられます --- README.md | 2 + TODO.md | 18 +- docs/DEVELOPMENT.md | 2 + docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md | 4 + docs/spec/CLI_HELP_FIRSTCUT.md | 4 + docs/spec/CLI_STATE_FIRSTCUT.md | 4 +- index.html | 2 +- scripts/mikuscore-cli.mjs | 134 ++++++------- src/ts/cli-api.ts | 262 +++++++++++++++++++++++--- tests/unit/mikuscore-cli.spec.ts | 167 ++++++++++++++++ 10 files changed, 502 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 7760dbe..fcef45b 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ Examples: - `npm run cli -- state apply-command --in score.musicxml --command-file command.json --out score.next.musicxml` - `npm run cli -- state diff --before score.before.musicxml --after score.after.musicxml` +For `state validate-command` / `state apply-command`, command payloads may target notes either by `targetNodeId` / `anchorNodeId` or by `selector` / `anchor_selector` derived from `state inspect-measure`. + For CLI and development details, see `docs/DEVELOPMENT.md` and `docs/spec/CLI_STEP1.md`. ## Screenshots diff --git a/TODO.md b/TODO.md index d40c2bf..9fdc238 100644 --- a/TODO.md +++ b/TODO.md @@ -91,7 +91,7 @@ - Cover file input, `stdin`, `--out`, and representative failure cases. - Keep `stdout` for payload and `stderr` for diagnostics only. -- [ ] Record a future-facing CLI design note for AI-mediated workflows. +- [x] Record a future-facing CLI design note for AI-mediated workflows. - Motivation: - `mikuproject` shows that a CLI can be designed simultaneously for human operators, Agent Skills, and the downstream generative-AI interaction layer - the valuable lesson is not only "add AI commands", but "design the CLI contract so each layer can use it safely" @@ -105,7 +105,7 @@ - `docs/future/CLI_ROADMAP.md` - `docs/future/AI_JSON_INTERFACE.md` -- [ ] Rebuild CLI taxonomy around `convert` / `render` / `state` while compatibility cost is still low. +- [x] Rebuild CLI taxonomy around `convert` / `render` / `state` while compatibility cost is still low. - Rationale: - current real-world CLI usage appears low enough that command-surface reconstruction is still feasible - `mikuproject` suggests that clearer top-level responsibility split can scale well @@ -123,7 +123,7 @@ - define help-text shape for top-level `convert` / `render` / `state` - decide migration wording from the current `convert`-first CLI to the rebuilt taxonomy -- [ ] Add a user-facing one-shot `ABC -> SVG` CLI flow without breaking the internal `MusicXML`-first pipeline. +- [x] Add a user-facing one-shot `ABC -> SVG` CLI flow without breaking the internal `MusicXML`-first pipeline. - Intended shape: - external UX should allow a direct score-rendering path for ABC input - internal flow should still remain `ABC -> MusicXML -> SVG` @@ -132,7 +132,7 @@ - whether `render` should accept only selected non-MusicXML inputs or remain narrow - how diagnostics should describe both the conversion and render stages when one-shot mode is used -- [ ] Improve CLI failure handling so uncaught runtime errors stop leaking as raw JavaScript failures. +- [x] Improve CLI failure handling so uncaught runtime errors stop leaking as raw JavaScript failures. - Goal: - turn current unhandled exception behavior into stable CLI-facing usage/processing failures - First slices: @@ -140,7 +140,7 @@ - ensure stderr messages are human-readable by default - ensure `--diagnostics json` can still describe failure cases structurally -- [ ] Define a first-cut CLI diagnostics contract modeled after the successful direction proven in `mikuproject`. +- [x] Define a first-cut CLI diagnostics contract modeled after the successful direction proven in `mikuproject`. - Scope: - `convert` - `render` @@ -151,7 +151,7 @@ - define whether multi-stage commands such as one-shot `ABC -> SVG` should report stage summaries - decide how much "kept vs dropped" conversion information can be surfaced briefly without becoming noisy -- [ ] Align future `state` CLI naming with the existing core command catalog instead of inventing a second edit model. +- [x] Align future `state` CLI naming with the existing core command catalog instead of inventing a second edit model. - Preserve: - existing bounded core commands such as `change_to_pitch`, `change_duration`, `insert_note_after`, `delete_note`, and `split_note` - Prefer: @@ -161,7 +161,7 @@ - exposing each core command as its own top-level CLI verb - introducing a whole-measure rewrite contract when a bounded command contract is sufficient -- [ ] Define the `state` first cut around canonical `MusicXML` inspection and bounded mutation. +- [x] Define the `state` first cut around canonical `MusicXML` inspection and bounded mutation. - Candidate initial commands: - `state summarize` - `state inspect-measure` @@ -173,7 +173,7 @@ - what minimum inspect output is needed to support note-targeted edits reliably - whether tempo-level light edits should enter through the same bounded command path -- [ ] Preserve "small edit" work as `MusicXML`-centered bounded mutation, not as a separate editing product line. +- [x] Preserve "small edit" work as `MusicXML`-centered bounded mutation, not as a separate editing product line. - Scope: - pitch change - duration change @@ -182,7 +182,7 @@ - Editorial note: - treat "small edit feature", "`MusicXML`-centered light edit", and "diff-based edit" as the same theme seen from different layers -- [ ] Explicitly keep some user suggestions out of near-term CLI scope. +- [x] Explicitly keep some user suggestions out of near-term CLI scope. - Defer or omit for now: - batch conversion in CLI itself - lyrics/melody alignment diagnostics diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9503bfd..ef0317e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -70,6 +70,7 @@ Input/output contract: - `stdin` / `stdout` remain text-only for `musicxml` and `musescore` - `render svg` also accepts `--from abc` as a one-shot path while still routing internally through canonical `MusicXML` - `state` commands operate on canonical `MusicXML` +- `state validate-command` / `state apply-command` accept command payloads that target notes either by `targetNodeId` / `anchorNodeId` or by `selector` / `anchor_selector` - `--diagnostics text|json` is available across current command families - plain-text CLI decode for `musicxml` / `musescore` is kept on UTF-8 `TextDecoder` rather than Node-only `Buffer`, so the same entrypoint can be runtime-compiled in isolated bundle environments - usage failures now use a distinct CLI error path from processing failures @@ -96,6 +97,7 @@ Examples: - `npm run cli -- state validate-command --in score.musicxml --command-file command.json` - `npm run cli -- state apply-command --in score.musicxml --command-file command.json --out score.next.musicxml` - `npm run cli -- state diff --before score.before.musicxml --after score.after.musicxml` +- `state inspect-measure` output can be fed back into `state validate-command` / `state apply-command` via `selector` / `anchor_selector` payload fields - `cat score.abc | npm run cli -- convert --from abc --to musicxml` Observed sibling-project direction: diff --git a/docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md b/docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md index 0e9757d..95a8564 100644 --- a/docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md +++ b/docs/spec/CLI_DIAGNOSTICS_FIRSTCUT.md @@ -184,6 +184,10 @@ Candidate optional JSON extension fields: These are optional first-cut extensions, not yet required minimum fields. +Current first-cut implementation note: + +- one-shot `render svg --from abc` now emits `stages` in JSON diagnostics so tool callers can see the internal `ABC -> MusicXML -> SVG` path without changing the primary artifact contract + ## "Kept vs Dropped" Direction One important future diagnostics goal is to make `mikuscore` more trustworthy as a converter by making conversion loss easier to notice. diff --git a/docs/spec/CLI_HELP_FIRSTCUT.md b/docs/spec/CLI_HELP_FIRSTCUT.md index 00d319d..5d642bb 100644 --- a/docs/spec/CLI_HELP_FIRSTCUT.md +++ b/docs/spec/CLI_HELP_FIRSTCUT.md @@ -129,6 +129,10 @@ Examples: mikuscore state validate-command --in score.musicxml --command-file command.json mikuscore state apply-command --in score.musicxml --command-file command.json --out score.next.musicxml mikuscore state diff --before score.before.musicxml --after score.after.musicxml + +Notes: + Command payloads may target notes either by targetNodeId/anchorNodeId + or by selector/anchor_selector values derived from state inspect-measure. ``` ## Help Tone diff --git a/docs/spec/CLI_STATE_FIRSTCUT.md b/docs/spec/CLI_STATE_FIRSTCUT.md index ca7e02e..55d6a77 100644 --- a/docs/spec/CLI_STATE_FIRSTCUT.md +++ b/docs/spec/CLI_STATE_FIRSTCUT.md @@ -143,6 +143,7 @@ Behavior: - MUST NOT invent a separate whole-measure rewrite contract - MUST return success or failure with diagnostics - MUST keep mutation semantics aligned with `docs/spec/COMMAND_CATALOG.md` +- current CLI implementation may also accept selector-style targeting hints and resolve them to bounded core node ids before dispatch Candidate command payloads include: @@ -169,6 +170,7 @@ Behavior: - MUST emit the next canonical `MusicXML` state on success - MUST fail atomically when command execution fails - MUST preserve the existing save/serialization policy defined in core specs +- current CLI implementation may also accept selector-style targeting hints and resolve them to bounded core node ids before dispatch ### 5. `state diff` @@ -185,7 +187,7 @@ Behavior: - SHOULD produce a compact difference summary rather than a raw textual XML diff - SHOULD focus on user-relevant score changes when practical -- MAY remain intentionally shallow in first cut if a deeper music-aware diff is not yet stable +- first cut may stay compact, but should still try to surface changed measure hints when they are cheap to derive ## Relationship To Core Command Catalog diff --git a/index.html b/index.html index 01188b5..1bec647 100644 --- a/index.html +++ b/index.html @@ -125,7 +125,7 @@

mikuscore

-

Updated: 2026-04-16

+

Updated: 2026-04-17

English

diff --git a/scripts/mikuscore-cli.mjs b/scripts/mikuscore-cli.mjs index ee05408..540cf90 100644 --- a/scripts/mikuscore-cli.mjs +++ b/scripts/mikuscore-cli.mjs @@ -121,6 +121,10 @@ const HELP_TEXT = { " apply-command Apply one bounded command and emit the next canonical MusicXML state", " diff Emit a compact JSON summary of differences between two canonical MusicXML states", "", + "Command payload note:", + " state validate-command/apply-command accept core command JSON.", + " Targeting may use targetNodeId/anchorNodeId directly or selector/anchor_selector from inspect-measure output.", + "", "Options:", " --diagnostics text|json Select diagnostics format", " --help Show help", @@ -273,12 +277,7 @@ async function runCommand(command, options, api) { } if (from === "musicxml" && to === "abc") { - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } - const inputText = decoded.output; + const inputText = await readMusicXmlInputText(options.in, api); const result = api.abc.exportFromMusicXml(inputText); if (!result.ok) { throw new CliCommandFailure(result, "MusicXML to ABC conversion failed."); @@ -296,12 +295,7 @@ async function runCommand(command, options, api) { } if (from === "musicxml" && to === "midi") { - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } - const inputText = decoded.output; + const inputText = await readMusicXmlInputText(options.in, api); const result = api.midi.exportFromMusicXml(inputText); if (!result.ok) { throw new CliCommandFailure(result, "MusicXML to MIDI conversion failed."); @@ -323,12 +317,7 @@ async function runCommand(command, options, api) { } if (from === "musicxml" && to === "musescore") { - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } - const inputText = decoded.output; + const inputText = await readMusicXmlInputText(options.in, api); const result = api.musescore.exportFromMusicXml(inputText); if (!result.ok) { throw new CliCommandFailure(result, "MusicXML to MuseScore conversion failed."); @@ -359,6 +348,20 @@ async function runCommand(command, options, api) { ...rendered, warnings: [...imported.warnings, ...rendered.warnings], diagnostics: [...imported.diagnostics, ...rendered.diagnostics], + stages: [ + { + name: "abc_to_musicxml", + status: imported.diagnostics.length > 0 ? "warning" : "success", + warning_count: imported.warnings.length, + error_count: imported.diagnostics.length, + }, + { + name: "musicxml_to_svg", + status: rendered.diagnostics.length > 0 ? "warning" : "success", + warning_count: rendered.warnings.length, + error_count: rendered.diagnostics.length, + }, + ], }; } @@ -368,12 +371,7 @@ async function runCommand(command, options, api) { }); } - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } - const inputText = decoded.output; + const inputText = await readMusicXmlInputText(options.in, api); const result = await api.render.svgFromMusicXml(inputText); if (!result.ok) { throw new CliCommandFailure(result, "SVG render failed."); @@ -382,16 +380,12 @@ async function runCommand(command, options, api) { } if (isCommand(command, ["state", "summarize"])) { - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } - const result = api.state.summarizeFromMusicXml(decoded.output); - if (!result.ok) { - throw new CliCommandFailure(result, "Failed to summarize MusicXML state."); - } - return result; + return runStateMusicXmlCommand( + options.in, + api, + (inputText) => api.state.summarizeFromMusicXml(inputText), + "Failed to summarize MusicXML state." + ); } if (isCommand(command, ["state", "inspect-measure"])) { @@ -399,44 +393,32 @@ async function runCommand(command, options, api) { if (!measure) { throw new CliUsageError("state inspect-measure requires --measure .", "missing_measure_option"); } - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } - const result = api.state.inspectMeasureFromMusicXml(decoded.output, measure); - if (!result.ok) { - throw new CliCommandFailure(result, "Failed to inspect MusicXML measure."); - } - return result; + return runStateMusicXmlCommand( + options.in, + api, + (inputText) => api.state.inspectMeasureFromMusicXml(inputText, measure), + "Failed to inspect MusicXML measure." + ); } if (isCommand(command, ["state", "validate-command"])) { - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } const commandPayload = await readCommandPayload(options); - const result = api.state.validateCommandFromMusicXml(decoded.output, commandPayload); - if (!result.ok) { - throw new CliCommandFailure(result, "Failed to validate MusicXML command."); - } - return result; + return runStateMusicXmlCommand( + options.in, + api, + (inputText) => api.state.validateCommandFromMusicXml(inputText, commandPayload), + "Failed to validate MusicXML command." + ); } if (isCommand(command, ["state", "apply-command"])) { - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } const commandPayload = await readCommandPayload(options); - const result = api.state.applyCommandFromMusicXml(decoded.output, commandPayload); - if (!result.ok) { - throw new CliCommandFailure(result, "Failed to apply MusicXML command."); - } - return result; + return runStateMusicXmlCommand( + options.in, + api, + (inputText) => api.state.applyCommandFromMusicXml(inputText, commandPayload), + "Failed to apply MusicXML command." + ); } if (isCommand(command, ["state", "diff"])) { @@ -463,6 +445,24 @@ async function runCommand(command, options, api) { throw new CliUsageError(`Unsupported command: ${command.join(" ")}`, "unsupported_command"); } +async function readMusicXmlInputText(inputPath, api) { + const inputBytes = await readBinaryInput(inputPath); + const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, inputPath); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); + } + return decoded.output; +} + +async function runStateMusicXmlCommand(inputPath, api, run, fallbackMessage) { + const inputText = await readMusicXmlInputText(inputPath, api); + const result = await run(inputText); + if (!result.ok) { + throw new CliCommandFailure(result, fallbackMessage); + } + return result; +} + async function encodeOutputForTarget(result, outPath, api, to) { if (!result.ok) return result; if (to === "musicxml" && typeof result.output === "string") { @@ -558,7 +558,7 @@ function buildSuccessDiagnostics(command, options, result) { const warnings = Array.isArray(result.warnings) ? result.warnings : []; const errors = Array.isArray(result.diagnostics) ? result.diagnostics : []; const status = errors.length > 0 ? "error" : warnings.length > 0 ? "warning" : "success"; - return { + const diagnostics = { ok: result.ok && errors.length === 0, diagnostics_version: DIAGNOSTICS_VERSION, command: command.join(" "), @@ -571,6 +571,10 @@ function buildSuccessDiagnostics(command, options, result) { warnings, errors, }; + if (Array.isArray(result.stages) && result.stages.length > 0) { + diagnostics.stages = result.stages; + } + return diagnostics; } function buildErrorDiagnostics(argv, error, exitCode) { diff --git a/src/ts/cli-api.ts b/src/ts/cli-api.ts index a2940e7..3a92401 100644 --- a/src/ts/cli-api.ts +++ b/src/ts/cli-api.ts @@ -71,6 +71,170 @@ const decodeUtf8Text = (bytes: Uint8Array): string => { return new TextDecoder("utf-8").decode(bytes); }; +type MeasureNoteSelector = { + part_id?: string | null; + measure_number?: string | null; + measure_note_index?: number | null; + voice?: string | null; + voice_note_index?: number | null; +}; + +type IndexedMeasureNote = { + nodeId: string; + selector: { + part_id: string | null; + measure_number: string; + measure_note_index: number; + voice: string | null; + voice_note_index: number; + }; +}; + +type CliCommandNormalizationResult = + | { + ok: true; + command: CoreCommand; + } + | { + ok: false; + message: string; + }; + +const buildIndexedMeasureNotes = (xmlText: string): IndexedMeasureNote[] => { + const doc = parseCoreXml(xmlText); + const nodeToId = new WeakMap(); + const idToNode = new Map(); + let sequence = 0; + reindexNodeIds(doc, nodeToId, idToNode, () => { + sequence += 1; + return `n${sequence}`; + }); + + return Array.from(doc.querySelectorAll("score-partwise > part > measure")).flatMap((measure) => { + const part = measure.parentElement; + const partId = part?.getAttribute("id")?.trim() ?? null; + const measureNumber = measure.getAttribute("number")?.trim() ?? ""; + const voiceNoteCounts = new Map(); + return Array.from(measure.querySelectorAll(":scope > note")).flatMap((note, noteIndex) => { + const nodeId = nodeToId.get(note); + if (!nodeId) return []; + const voice = getVoiceText(note); + const voiceKey = voice ?? "__none__"; + const nextVoiceNoteIndex = (voiceNoteCounts.get(voiceKey) ?? 0) + 1; + voiceNoteCounts.set(voiceKey, nextVoiceNoteIndex); + return [{ + nodeId, + selector: { + part_id: partId, + measure_number: measureNumber, + measure_note_index: noteIndex + 1, + voice, + voice_note_index: nextVoiceNoteIndex, + }, + }]; + }); + }); +}; + +const resolveMeasureNoteSelector = ( + selector: MeasureNoteSelector | undefined, + indexedNotes: IndexedMeasureNote[], + selectorName: string +): { ok: true; nodeId: string; voice?: string | null } | { ok: false; message: string } => { + if (!selector || typeof selector !== "object") { + return { + ok: false, + message: `${selectorName} must be an object when provided.`, + }; + } + + const normalized = { + part_id: selector.part_id == null ? undefined : String(selector.part_id), + measure_number: selector.measure_number == null ? undefined : String(selector.measure_number), + measure_note_index: Number.isInteger(selector.measure_note_index) ? Number(selector.measure_note_index) : undefined, + voice: selector.voice == null ? undefined : String(selector.voice), + voice_note_index: Number.isInteger(selector.voice_note_index) ? Number(selector.voice_note_index) : undefined, + }; + + const activeKeys = Object.entries(normalized).filter(([, value]) => value !== undefined); + if (activeKeys.length === 0) { + return { + ok: false, + message: `${selectorName} must include at least one selector field.`, + }; + } + + const matches = indexedNotes.filter((note) => { + return activeKeys.every(([key, value]) => note.selector[key as keyof typeof note.selector] === value); + }); + + if (matches.length === 0) { + return { + ok: false, + message: `${selectorName} did not match any note in the current MusicXML state.`, + }; + } + + if (matches.length > 1) { + return { + ok: false, + message: `${selectorName} matched multiple notes; add more selector fields to disambiguate.`, + }; + } + + return { + ok: true, + nodeId: matches[0].nodeId, + voice: matches[0].selector.voice, + }; +}; + +const normalizeCliCommandSelectors = (xmlText: string, command: CoreCommand): CliCommandNormalizationResult => { + const commandObject = command as Record; + const indexedNotes = buildIndexedMeasureNotes(xmlText); + const nextCommand = { ...commandObject }; + + if ("selector" in nextCommand && !("targetNodeId" in nextCommand)) { + const resolved = resolveMeasureNoteSelector(nextCommand.selector as MeasureNoteSelector | undefined, indexedNotes, "selector"); + if (!resolved.ok) { + return { + ok: false, + message: `Failed to resolve CLI command selector: ${resolved.message}`, + }; + } + nextCommand.targetNodeId = resolved.nodeId; + if (!("voice" in nextCommand) && resolved.voice != null) { + nextCommand.voice = resolved.voice; + } + } + + if ("anchor_selector" in nextCommand && !("anchorNodeId" in nextCommand)) { + const resolved = resolveMeasureNoteSelector( + nextCommand.anchor_selector as MeasureNoteSelector | undefined, + indexedNotes, + "anchor_selector" + ); + if (!resolved.ok) { + return { + ok: false, + message: `Failed to resolve CLI command selector: ${resolved.message}`, + }; + } + nextCommand.anchorNodeId = resolved.nodeId; + if (!("voice" in nextCommand) && resolved.voice != null) { + nextCommand.voice = resolved.voice; + } + } + + delete nextCommand.selector; + delete nextCommand.anchor_selector; + + return { + ok: true, + command: nextCommand as CoreCommand, + }; +}; + export const decodeCliMusicXmlInput = async (inputBytes: Uint8Array, inputPath?: string): Promise => { const name = lowerFileName(inputPath); try { @@ -382,9 +546,11 @@ export const summarizeMusicXmlState = (xmlText: string): CliResult => { export const validateMusicXmlCommand = (xmlText: string, command: CoreCommand): CliResult => { try { + const normalized = normalizeCliCommandSelectors(xmlText, command); + if (!normalized.ok) return failureResult(normalized.message); const core = new ScoreCore(); core.load(xmlText); - const result = core.dispatch(command); + const result = core.dispatch(normalized.command); return { ok: true, output: `${JSON.stringify( @@ -414,9 +580,11 @@ export const validateMusicXmlCommand = (xmlText: string, command: CoreCommand): export const applyMusicXmlCommand = (xmlText: string, command: CoreCommand): CliResult => { try { + const normalized = normalizeCliCommandSelectors(xmlText, command); + if (!normalized.ok) return failureResult(normalized.message); const core = new ScoreCore(); core.load(xmlText); - const result = core.dispatch(command); + const result = core.dispatch(normalized.command); if (!result.ok) { return { ok: true, @@ -463,43 +631,36 @@ export const applyMusicXmlCommand = (xmlText: string, command: CoreCommand): Cli export const inspectMusicXmlMeasure = (xmlText: string, measureNumber: string): CliResult => { try { + const indexedNotes = buildIndexedMeasureNotes(xmlText); const doc = parseCoreXml(xmlText); - const nodeToId = new WeakMap(); - const idToNode = new Map(); - let sequence = 0; - reindexNodeIds(doc, nodeToId, idToNode, () => { - sequence += 1; - return `n${sequence}`; - }); - - const measures = Array.from(doc.querySelectorAll("score-partwise > part > measure")) + const matchingMeasures = Array.from(doc.querySelectorAll("score-partwise > part > measure")) .filter((measure) => (measure.getAttribute("number")?.trim() ?? "") === measureNumber); const summary = { kind: "musicxml_measure_inspection", measure_number: measureNumber, - measures: measures.map((measure) => { + measures: matchingMeasures.map((measure) => { const part = measure.parentElement; const partId = part?.getAttribute("id")?.trim() ?? null; - const voiceNoteCounts = new Map(); const notes = Array.from(measure.querySelectorAll(":scope > note")).map((note, noteIndex) => { - const nodeId = nodeToId.get(note) ?? null; + const indexed = indexedNotes.find((item) => + item.selector.part_id === partId && + item.selector.measure_number === measureNumber && + item.selector.measure_note_index === noteIndex + 1 + ); const voice = getVoiceText(note); const step = note.querySelector(":scope > pitch > step")?.textContent?.trim() ?? null; const octaveText = note.querySelector(":scope > pitch > octave")?.textContent?.trim() ?? null; const alterText = note.querySelector(":scope > pitch > alter")?.textContent?.trim() ?? null; const alter = alterText === null ? null : Number(alterText); - const voiceKey = voice ?? "__none__"; - const nextVoiceNoteIndex = (voiceNoteCounts.get(voiceKey) ?? 0) + 1; - voiceNoteCounts.set(voiceKey, nextVoiceNoteIndex); return { - node_id: nodeId, - selector: { + node_id: indexed?.nodeId ?? null, + selector: indexed?.selector ?? { part_id: partId, measure_number: measureNumber, measure_note_index: noteIndex + 1, voice, - voice_note_index: nextVoiceNoteIndex, + voice_note_index: null, }, voice, duration: getDurationValue(note), @@ -558,22 +719,81 @@ const buildMusicXmlStateSummaryObject = (doc: Document) => { }; }; +const buildMeasureDiffSignatures = (doc: Document) => { + return Array.from(doc.querySelectorAll("score-partwise > part > measure")).map((measure) => { + const partId = measure.parentElement?.getAttribute("id")?.trim() ?? null; + const measureNumber = measure.getAttribute("number")?.trim() ?? ""; + const noteSummary = Array.from(measure.querySelectorAll(":scope > note")).map((note) => { + const voice = getVoiceText(note); + const duration = getDurationValue(note); + const isRest = note.querySelector(":scope > rest") !== null; + const step = note.querySelector(":scope > pitch > step")?.textContent?.trim() ?? null; + const octave = note.querySelector(":scope > pitch > octave")?.textContent?.trim() ?? null; + const alter = note.querySelector(":scope > pitch > alter")?.textContent?.trim() ?? null; + return { + voice, + duration, + is_rest: isRest, + pitch: isRest || !step || !octave + ? null + : { + step, + alter: alter == null ? null : Number(alter), + octave: Number(octave), + }, + }; + }); + return { + part_id: partId, + measure_number: measureNumber, + note_count: noteSummary.length, + signature: JSON.stringify(noteSummary), + }; + }); +}; + export const diffMusicXmlState = (beforeXml: string, afterXml: string): CliResult => { try { const beforeDoc = parseCoreXml(beforeXml); const afterDoc = parseCoreXml(afterXml); const beforeSummary = buildMusicXmlStateSummaryObject(beforeDoc); const afterSummary = buildMusicXmlStateSummaryObject(afterDoc); + const beforeMeasures = buildMeasureDiffSignatures(beforeDoc); + const afterMeasures = buildMeasureDiffSignatures(afterDoc); const changedFields = Object.keys(beforeSummary).filter((key) => { return JSON.stringify(beforeSummary[key as keyof typeof beforeSummary]) !== JSON.stringify(afterSummary[key as keyof typeof afterSummary]); }); + const beforeMeasureMap = new Map(beforeMeasures.map((item) => [`${item.part_id ?? ""}:${item.measure_number}`, item])); + const afterMeasureMap = new Map(afterMeasures.map((item) => [`${item.part_id ?? ""}:${item.measure_number}`, item])); + const changedMeasureKeys = Array.from(new Set([...beforeMeasureMap.keys(), ...afterMeasureMap.keys()])).filter((key) => { + const beforeItem = beforeMeasureMap.get(key); + const afterItem = afterMeasureMap.get(key); + if (!beforeItem || !afterItem) return true; + return beforeItem.signature !== afterItem.signature; + }); + const diff = { kind: "musicxml_state_diff", - changed: changedFields.length > 0, + changed: changedFields.length > 0 || changedMeasureKeys.length > 0, changed_fields: changedFields, + changed_measure_numbers: changedMeasureKeys + .map((key) => afterMeasureMap.get(key) ?? beforeMeasureMap.get(key)) + .filter((item): item is NonNullable => item != null) + .map((item) => item.measure_number), + changed_measures: changedMeasureKeys + .map((key) => { + const beforeItem = beforeMeasureMap.get(key); + const afterItem = afterMeasureMap.get(key); + return { + part_id: afterItem?.part_id ?? beforeItem?.part_id ?? null, + measure_number: afterItem?.measure_number ?? beforeItem?.measure_number ?? "", + before_note_count: beforeItem?.note_count ?? 0, + after_note_count: afterItem?.note_count ?? 0, + }; + }), before: beforeSummary, after: afterSummary, }; diff --git a/tests/unit/mikuscore-cli.spec.ts b/tests/unit/mikuscore-cli.spec.ts index 46dfd88..6e74cd1 100644 --- a/tests/unit/mikuscore-cli.spec.ts +++ b/tests/unit/mikuscore-cli.spec.ts @@ -56,6 +56,7 @@ describe("mikuscore cli", () => { expect(stateHelp.stdout).toContain("validate-command"); expect(stateHelp.stdout).toContain("apply-command"); expect(stateHelp.stdout).toContain("diff"); + expect(stateHelp.stdout).toContain("selector/anchor_selector"); expect(stateHelp.stderr).toBe(""); }, 10000); @@ -194,6 +195,35 @@ describe("mikuscore cli", () => { expect(validation.affected_measure_numbers).toEqual(["1"]); }); + it("validates one bounded MusicXML command via selector", () => { + const result = runCli( + [ + "state", + "validate-command", + "--command", + JSON.stringify({ + type: "change_to_pitch", + selector: { + part_id: "P1", + measure_number: "1", + measure_note_index: 1, + voice: "1", + }, + pitch: { step: "G", octave: 4 }, + }), + ], + { + input: validEditableMusicXml("Validate selector command"), + } + ); + + expect(result.status).toBe(0); + const validation = JSON.parse(result.stdout); + expect(validation.kind).toBe("musicxml_command_validation"); + expect(validation.ok).toBe(true); + expect(validation.changed_node_ids).toEqual(["n1"]); + }); + it("inspects one measure for edit targeting", () => { const result = runCli(["state", "inspect-measure", "--measure", "1"], { input: validEditableMusicXml("Inspect measure"), @@ -240,6 +270,64 @@ describe("mikuscore cli", () => { expect(result.stdout).toContain("4"); }); + it("applies one bounded MusicXML command via selector", () => { + const result = runCli( + [ + "state", + "apply-command", + "--command", + JSON.stringify({ + type: "change_to_pitch", + selector: { + part_id: "P1", + measure_number: "1", + measure_note_index: 1, + voice: "1", + }, + pitch: { step: "A", octave: 4 }, + }), + ], + { + input: validEditableMusicXml("Apply selector command"), + } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("A"); + expect(result.stdout).toContain("4"); + }); + + it("applies insert_note_after via anchor_selector", () => { + const result = runCli( + [ + "state", + "apply-command", + "--command", + JSON.stringify({ + type: "insert_note_after", + anchor_selector: { + part_id: "P1", + measure_number: "1", + measure_note_index: 1, + voice: "1", + }, + note: { + duration: 1, + pitch: { step: "A", octave: 4 }, + }, + }), + ], + { + input: validInsertableMusicXml("Apply anchor selector command"), + } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("A"); + expect(result.stdout).toContain("D"); + expect(result.stdout.match(//g)?.length).toBe(4); + }); + it("diffs two canonical MusicXML states", () => { const beforePath = writeTempFile("before.musicxml", validEditableMusicXml("Before title")); const afterPath = writeTempFile("after.musicxml", validEditableMusicXml("After title").replace("C", "G")); @@ -251,6 +339,15 @@ describe("mikuscore cli", () => { expect(diff.kind).toBe("musicxml_state_diff"); expect(diff.changed).toBe(true); expect(diff.changed_fields).toContain("title"); + expect(diff.changed_measure_numbers).toEqual(["1"]); + expect(diff.changed_measures).toEqual([ + { + part_id: "P1", + measure_number: "1", + before_note_count: 4, + after_note_count: 4, + }, + ]); expect(diff.before.title).toBe("Before title"); expect(diff.after.title).toBe("After title"); }); @@ -270,6 +367,32 @@ describe("mikuscore cli", () => { expect(diagnostics.exit_code).toBe(0); expect(diagnostics.output?.mode).toBeUndefined(); expect(diagnostics.io.output).toEqual({ mode: "stdout" }); + expect(diagnostics.stages).toBeUndefined(); + }); + + it("writes stage-aware diagnostics for one-shot render json output", () => { + const inputPath = writeTempFile("score.abc", "X:1\nT:Stage diagnostics\nM:4/4\nL:1/4\nK:C\nC D E F|\n"); + const result = runCli(["render", "svg", "--from", "abc", "--in", inputPath, "--diagnostics", "json"]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(" { @@ -303,6 +426,25 @@ describe("mikuscore cli", () => { const missingCommandPayload = runCli(["state", "validate-command"], { input: validEditableMusicXml("Missing command"), }); + const unresolvedSelector = runCli( + [ + "state", + "validate-command", + "--command", + JSON.stringify({ + type: "change_to_pitch", + selector: { + part_id: "P1", + measure_number: "99", + measure_note_index: 1, + }, + pitch: { step: "G", octave: 4 }, + }), + ], + { + input: validEditableMusicXml("Bad selector"), + } + ); const missingMeasureOption = runCli(["state", "inspect-measure"], { input: validEditableMusicXml("Missing measure"), }); @@ -322,6 +464,8 @@ describe("mikuscore cli", () => { expect(unsupportedRenderSource.stderr).toContain("Unsupported render source"); expect(missingCommandPayload.status).toBe(2); expect(missingCommandPayload.stderr).toContain("requires exactly one of --command"); + expect(unresolvedSelector.status).toBe(1); + expect(unresolvedSelector.stderr).toContain("Failed to resolve CLI command selector"); expect(missingMeasureOption.status).toBe(2); expect(missingMeasureOption.stderr).toContain("requires --measure"); expect(missingDiffInputs.status).toBe(2); @@ -406,3 +550,26 @@ function validEditableMusicXml(title: string) { `; } + +function validInsertableMusicXml(title: string) { + return ` + + ${title} + + Music + + + + + 1 + 0 + + G2 + + C411quarter + D411quarter + E411quarter + + +`; +} From 7367d36992cff6d6544158791ae11b1f50e18bc9 Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Fri, 17 Apr 2026 23:12:24 +0900 Subject: [PATCH 5/8] =?UTF-8?q?CLI=20=E5=AE=9F=E8=A3=85=E3=82=92=E6=95=B4?= =?UTF-8?q?=E7=90=86=E3=81=97=E3=80=81=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89?= =?UTF-8?q?=E5=88=86=E5=B2=90=E3=83=BB=E5=85=A5=E5=87=BA=E5=8A=9B=E5=87=A6?= =?UTF-8?q?=E7=90=86=E3=83=BBdiagnostics=20=E7=B5=84=E3=81=BF=E7=AB=8B?= =?UTF-8?q?=E3=81=A6=E3=82=92=E5=85=B1=E9=80=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 `scripts/mikuscore-cli.mjs` の内部構造を整理し、CLI の分岐処理・入力デコード・diagnostics 構築を helper ベースへ寄せます。 外向きのコマンド体系を大きく変えるのではなく、実装の責務分離と重複削減を進める変更です。 ## 変更内容 ### 1. help / command routing の整理 - `main()` での help 判定を `resolveHelpTopic(...)` に切り出し - top-level command の実行を `runConvertCommand(...)` / `runRenderCommand(...)` / `runStateCommand(...)` に分離 - `runCommand(...)` を command family の routing 中心に整理 ### 2. option parsing の整理 - `parseArgs(...)` 内で option value 取得を `readOptionValue(...)` に共通化 - `--diagnostics` の値検証を `validateDiagnosticsOption(...)` に切り出し ### 3. convert / render / state の handler 化 - `convert` の変換 pair ごとの処理を `buildConvertHandlers(...)` に集約 - `render` の source ごとの処理を `buildRenderHandlers(...)` に集約 - `state` の subcommand ごとの処理を `buildStateHandlers(...)` に集約 ### 4. 入出力・変換 helper の整理 以下の helper を追加・整理し、MusicXML 読み込みや import/export の重複を削減しています。 - `readDecodedTextInput(...)` - `runMusicXmlTextCommand(...)` - `runMusicXmlExportCommand(...)` - `runEncodedMusicXmlExportCommand(...)` - `runTextImportCommand(...)` - `runBinaryImportCommand(...)` - `runDecodedTextImportCommand(...)` - `runEncodedImportCommand(...)` ### 5. one-shot render の処理整理 - `ABC -> MusicXML -> SVG` の one-shot render を `runAbcToSvgRenderCommand(...)` に切り出し - stage 情報の生成を `buildStageDiagnostics(...)` に分離 ### 6. diagnostics 構築の共通化 - diagnostics の基底組み立てを `buildBaseDiagnostics(...)` に整理 - success / error 共通の集計を `summarizeDiagnosticOutcome(...)` に集約 - error diagnostics 用の `argv` 解析を `summarizeArgv(...)` に整理し、I/O diagnostics 用の二重走査を削減 ## 意図 - `scripts/mikuscore-cli.mjs` の責務を分解し、読みやすさを上げる - `convert` / `render` / `state` の dispatch 形状を揃える - input decode / import / export / diagnostics の重複を減らす - 今後の CLI 拡張時に command family や subcommand を追加しやすくする --- scripts/mikuscore-cli.mjs | 576 ++++++++++++++++++++++---------------- 1 file changed, 329 insertions(+), 247 deletions(-) diff --git a/scripts/mikuscore-cli.mjs b/scripts/mikuscore-cli.mjs index 540cf90..9a38e0b 100644 --- a/scripts/mikuscore-cli.mjs +++ b/scripts/mikuscore-cli.mjs @@ -176,24 +176,10 @@ main().catch((error) => { async function main() { const { command, options } = parseArgs(process.argv.slice(2)); + const helpTopic = resolveHelpTopic(command, options); - if (command.length === 0 || (options.help && !options.helpCommand)) { - writeHelp(process.stdout, "top"); - return; - } - - if (isCommand(command, ["convert"]) && options.helpCommand) { - writeHelp(process.stdout, "convert"); - return; - } - - if (isCommand(command, ["render"]) && options.helpCommand) { - writeHelp(process.stdout, "render"); - return; - } - - if (isCommand(command, ["state"]) && options.helpCommand) { - writeHelp(process.stdout, "state"); + if (helpTopic) { + writeHelp(process.stdout, helpTopic); return; } @@ -224,24 +210,9 @@ function parseArgs(argv) { continue; } + const value = readOptionValue(argv, index, token); if (key === "diagnostics") { - const diagnosticsValue = argv[index + 1]; - if (!diagnosticsValue || diagnosticsValue.startsWith("--")) { - throw new CliUsageError(`Option ${token} requires a value.`, "missing_option_value", { option: token }); - } - if (diagnosticsValue !== "text" && diagnosticsValue !== "json") { - throw new CliUsageError("--diagnostics must be either text or json.", "invalid_diagnostics_option", { - option: "--diagnostics", - }); - } - options.diagnostics = diagnosticsValue; - index += 1; - continue; - } - - const value = argv[index + 1]; - if (value === undefined || value.startsWith("--")) { - throw new CliUsageError(`Option ${token} requires a value.`, "missing_option_value", { option: token }); + validateDiagnosticsOption(value); } options[key] = value; index += 1; @@ -258,203 +229,237 @@ function isCommand(actual, expected) { return actual.length === expected.length && actual.every((value, index) => value === expected[index]); } -async function runCommand(command, options, api) { - if (isCommand(command, ["convert"])) { - const from = String(options.from || "").trim().toLowerCase(); - const to = String(options.to || "").trim().toLowerCase(); - - if (!from || !to) { - throw new CliUsageError("convert requires both --from and --to .", "missing_from_to"); - } - - if (from === "abc" && to === "musicxml") { - const inputText = await readTextInput(options.in); - const result = api.abc.importToMusicXml(inputText); - if (!result.ok) { - throw new CliCommandFailure(result, "ABC to MusicXML conversion failed."); - } - return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; - } +function readOptionValue(argv, index, token) { + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new CliUsageError(`Option ${token} requires a value.`, "missing_option_value", { option: token }); + } + return value; +} - if (from === "musicxml" && to === "abc") { - const inputText = await readMusicXmlInputText(options.in, api); - const result = api.abc.exportFromMusicXml(inputText); - if (!result.ok) { - throw new CliCommandFailure(result, "MusicXML to ABC conversion failed."); - } - return result; - } +function validateDiagnosticsOption(value) { + if (value !== "text" && value !== "json") { + throw new CliUsageError("--diagnostics must be either text or json.", "invalid_diagnostics_option", { + option: "--diagnostics", + }); + } +} - if (from === "midi" && to === "musicxml") { - const inputBytes = await readBinaryInput(options.in); - const result = api.midi.importToMusicXml(inputBytes); - if (!result.ok) { - throw new CliCommandFailure(result, "MIDI to MusicXML conversion failed."); - } - return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; - } +function resolveHelpTopic(command, options) { + if (command.length === 0 || (options.help && !options.helpCommand)) { + return "top"; + } - if (from === "musicxml" && to === "midi") { - const inputText = await readMusicXmlInputText(options.in, api); - const result = api.midi.exportFromMusicXml(inputText); - if (!result.ok) { - throw new CliCommandFailure(result, "MusicXML to MIDI conversion failed."); - } - return result; - } + const helpTopics = { + convert: "convert", + render: "render", + state: "state", + }; - if (from === "musescore" && to === "musicxml") { - const inputBytes = await readBinaryInput(options.in); - const decoded = await api.fileIO.musescore.decodeInput(inputBytes, options.in); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MuseScore input."); - } - const result = api.musescore.importToMusicXml(decoded.output); - if (!result.ok) { - throw new CliCommandFailure(result, "MuseScore to MusicXML conversion failed."); - } - return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; - } + if (options.helpCommand && command.length === 1) { + return helpTopics[command[0]]; + } - if (from === "musicxml" && to === "musescore") { - const inputText = await readMusicXmlInputText(options.in, api); - const result = api.musescore.exportFromMusicXml(inputText); - if (!result.ok) { - throw new CliCommandFailure(result, "MusicXML to MuseScore conversion failed."); - } - return options.out ? await encodeOutputForTarget(result, options.out, api, to) : result; - } + return undefined; +} - throw new CliUsageError(`Unsupported conversion pair: --from ${from} --to ${to}`, "unsupported_conversion_pair", { - from, - to, - }); +async function runCommand(command, options, api) { + if (isCommand(command, ["convert"])) { + return runConvertCommand(options, api); } if (isCommand(command, ["render", "svg"])) { - const from = String(options.from || "musicxml").trim().toLowerCase(); + return runRenderCommand(options, api); + } - if (from === "abc") { - const inputText = await readTextInput(options.in); - const imported = api.abc.importToMusicXml(inputText); - if (!imported.ok || typeof imported.output !== "string") { - throw new CliCommandFailure(imported, "ABC to MusicXML conversion failed."); - } - const rendered = await api.render.svgFromMusicXml(imported.output); - if (!rendered.ok) { - throw new CliCommandFailure(rendered, "SVG render failed."); - } - return { - ...rendered, - warnings: [...imported.warnings, ...rendered.warnings], - diagnostics: [...imported.diagnostics, ...rendered.diagnostics], - stages: [ - { - name: "abc_to_musicxml", - status: imported.diagnostics.length > 0 ? "warning" : "success", - warning_count: imported.warnings.length, - error_count: imported.diagnostics.length, - }, - { - name: "musicxml_to_svg", - status: rendered.diagnostics.length > 0 ? "warning" : "success", - warning_count: rendered.warnings.length, - error_count: rendered.diagnostics.length, - }, - ], - }; - } + if (command[0] === "state" && command.length >= 2) { + return runStateCommand(command.slice(1).join(" "), options, api); + } - if (from !== "musicxml") { - throw new CliUsageError(`Unsupported render source: --from ${from}`, "unsupported_render_source", { - from, - }); - } + throw new CliUsageError(`Unsupported command: ${command.join(" ")}`, "unsupported_command"); +} - const inputText = await readMusicXmlInputText(options.in, api); - const result = await api.render.svgFromMusicXml(inputText); - if (!result.ok) { - throw new CliCommandFailure(result, "SVG render failed."); - } - return result; - } +function runConvertCommand(options, api) { + const from = String(options.from || "").trim().toLowerCase(); + const to = String(options.to || "").trim().toLowerCase(); - if (isCommand(command, ["state", "summarize"])) { - return runStateMusicXmlCommand( - options.in, - api, - (inputText) => api.state.summarizeFromMusicXml(inputText), - "Failed to summarize MusicXML state." - ); + if (!from || !to) { + throw new CliUsageError("convert requires both --from and --to .", "missing_from_to"); } - if (isCommand(command, ["state", "inspect-measure"])) { - const measure = String(options.measure || "").trim(); - if (!measure) { - throw new CliUsageError("state inspect-measure requires --measure .", "missing_measure_option"); - } - return runStateMusicXmlCommand( - options.in, - api, - (inputText) => api.state.inspectMeasureFromMusicXml(inputText, measure), - "Failed to inspect MusicXML measure." - ); + const convertHandler = buildConvertHandlers(options, api)[`${from}:${to}`]; + if (convertHandler) { + return convertHandler(); } - if (isCommand(command, ["state", "validate-command"])) { - const commandPayload = await readCommandPayload(options); - return runStateMusicXmlCommand( - options.in, - api, - (inputText) => api.state.validateCommandFromMusicXml(inputText, commandPayload), - "Failed to validate MusicXML command." - ); - } + throw new CliUsageError(`Unsupported conversion pair: --from ${from} --to ${to}`, "unsupported_conversion_pair", { + from, + to, + }); +} - if (isCommand(command, ["state", "apply-command"])) { - const commandPayload = await readCommandPayload(options); - return runStateMusicXmlCommand( - options.in, - api, - (inputText) => api.state.applyCommandFromMusicXml(inputText, commandPayload), - "Failed to apply MusicXML command." - ); +function runRenderCommand(options, api) { + const from = String(options.from || "musicxml").trim().toLowerCase(); + const renderHandler = buildRenderHandlers(options, api)[from]; + if (renderHandler) { + return renderHandler(); } + throw new CliUsageError(`Unsupported render source: --from ${from}`, "unsupported_render_source", { + from, + }); +} - if (isCommand(command, ["state", "diff"])) { - if (!options.before || !options.after) { - throw new CliUsageError("state diff requires both --before and --after .", "missing_diff_inputs"); - } - const beforeBytes = await readBinaryInput(options.before); - const afterBytes = await readBinaryInput(options.after); - const beforeDecoded = await api.fileIO.musicxml.decodeInput(beforeBytes, options.before); - const afterDecoded = await api.fileIO.musicxml.decodeInput(afterBytes, options.after); - if (!beforeDecoded.ok || typeof beforeDecoded.output !== "string") { - throw new CliCommandFailure(beforeDecoded, "Failed to read before MusicXML input."); - } - if (!afterDecoded.ok || typeof afterDecoded.output !== "string") { - throw new CliCommandFailure(afterDecoded, "Failed to read after MusicXML input."); - } - const result = api.state.diffMusicXmlState(beforeDecoded.output, afterDecoded.output); - if (!result.ok) { - throw new CliCommandFailure(result, "Failed to diff MusicXML state."); - } - return result; +function runStateCommand(stateKey, options, api) { + const stateHandler = buildStateHandlers(options, api)[stateKey]; + if (stateHandler) { + return stateHandler(); } + throw new CliUsageError(`Unsupported command: state ${stateKey}`, "unsupported_command"); +} - throw new CliUsageError(`Unsupported command: ${command.join(" ")}`, "unsupported_command"); +function buildConvertHandlers(options, api) { + const to = String(options.to || "").trim().toLowerCase(); + return { + "abc:musicxml": async () => + runEncodedImportCommand(options.in, options.out, api, to, (inputPath) => + runTextImportCommand( + inputPath, + (inputText) => api.abc.importToMusicXml(inputText), + "ABC to MusicXML conversion failed." + ) + ), + "musicxml:abc": async () => + runMusicXmlExportCommand( + options.in, + api, + (inputText) => api.abc.exportFromMusicXml(inputText), + "MusicXML to ABC conversion failed." + ), + "midi:musicxml": async () => + runEncodedImportCommand(options.in, options.out, api, to, (inputPath) => + runBinaryImportCommand( + inputPath, + (inputBytes) => api.midi.importToMusicXml(inputBytes), + "MIDI to MusicXML conversion failed." + ) + ), + "musicxml:midi": async () => + runMusicXmlExportCommand( + options.in, + api, + (inputText) => api.midi.exportFromMusicXml(inputText), + "MusicXML to MIDI conversion failed." + ), + "musescore:musicxml": async () => + runEncodedImportCommand(options.in, options.out, api, to, async (inputPath) => { + const result = await runDecodedTextImportCommand( + inputPath, + (inputText) => api.musescore.importToMusicXml(inputText), + (inputBytes, sourcePath) => api.fileIO.musescore.decodeInput(inputBytes, sourcePath), + "Failed to read MuseScore input." + ); + if (!result.ok) { + throw new CliCommandFailure(result, "MuseScore to MusicXML conversion failed."); + } + return result; + }), + "musicxml:musescore": async () => + runEncodedMusicXmlExportCommand( + options.in, + options.out, + api, + to, + (inputText) => api.musescore.exportFromMusicXml(inputText), + "MusicXML to MuseScore conversion failed." + ), + }; +} + +function buildRenderHandlers(options, api) { + return { + abc: async () => runAbcToSvgRenderCommand(options.in, api), + musicxml: async () => + runMusicXmlTextCommand( + options.in, + api, + (inputText) => api.render.svgFromMusicXml(inputText), + "SVG render failed." + ), + }; +} + +function buildStateHandlers(options, api) { + return { + summarize: async () => { + return runMusicXmlTextCommand( + options.in, + api, + (inputText) => api.state.summarizeFromMusicXml(inputText), + "Failed to summarize MusicXML state." + ); + }, + "inspect-measure": async () => { + const measure = String(options.measure || "").trim(); + if (!measure) { + throw new CliUsageError("state inspect-measure requires --measure .", "missing_measure_option"); + } + return runMusicXmlTextCommand( + options.in, + api, + (inputText) => api.state.inspectMeasureFromMusicXml(inputText, measure), + "Failed to inspect MusicXML measure." + ); + }, + "validate-command": async () => { + const commandPayload = await readCommandPayload(options); + return runMusicXmlTextCommand( + options.in, + api, + (inputText) => api.state.validateCommandFromMusicXml(inputText, commandPayload), + "Failed to validate MusicXML command." + ); + }, + "apply-command": async () => { + const commandPayload = await readCommandPayload(options); + return runMusicXmlTextCommand( + options.in, + api, + (inputText) => api.state.applyCommandFromMusicXml(inputText, commandPayload), + "Failed to apply MusicXML command." + ); + }, + diff: async () => { + if (!options.before || !options.after) { + throw new CliUsageError("state diff requires both --before and --after .", "missing_diff_inputs"); + } + const beforeText = await readDecodedTextInput( + options.before, + (inputBytes, inputPath) => api.fileIO.musicxml.decodeInput(inputBytes, inputPath), + "Failed to read before MusicXML input." + ); + const afterText = await readDecodedTextInput( + options.after, + (inputBytes, inputPath) => api.fileIO.musicxml.decodeInput(inputBytes, inputPath), + "Failed to read after MusicXML input." + ); + const result = api.state.diffMusicXmlState(beforeText, afterText); + if (!result.ok) { + throw new CliCommandFailure(result, "Failed to diff MusicXML state."); + } + return result; + }, + }; } async function readMusicXmlInputText(inputPath, api) { - const inputBytes = await readBinaryInput(inputPath); - const decoded = await api.fileIO.musicxml.decodeInput(inputBytes, inputPath); - if (!decoded.ok || typeof decoded.output !== "string") { - throw new CliCommandFailure(decoded, "Failed to read MusicXML input."); - } - return decoded.output; + return readDecodedTextInput( + inputPath, + (inputBytes, inputFilePath) => api.fileIO.musicxml.decodeInput(inputBytes, inputFilePath), + "Failed to read MusicXML input." + ); } -async function runStateMusicXmlCommand(inputPath, api, run, fallbackMessage) { +async function runMusicXmlTextCommand(inputPath, api, run, fallbackMessage) { const inputText = await readMusicXmlInputText(inputPath, api); const result = await run(inputText); if (!result.ok) { @@ -463,6 +468,73 @@ async function runStateMusicXmlCommand(inputPath, api, run, fallbackMessage) { return result; } +async function runMusicXmlExportCommand(inputPath, api, run, fallbackMessage) { + return runMusicXmlTextCommand(inputPath, api, run, fallbackMessage); +} + +async function runEncodedMusicXmlExportCommand(inputPath, outPath, api, to, run, fallbackMessage) { + const result = await runMusicXmlExportCommand(inputPath, api, run, fallbackMessage); + return outPath ? encodeOutputForTarget(result, outPath, api, to) : result; +} + +async function runTextImportCommand(inputPath, run, fallbackMessage) { + const inputText = await readTextInput(inputPath); + const result = await run(inputText); + if (!result.ok) { + throw new CliCommandFailure(result, fallbackMessage); + } + return result; +} + +async function runBinaryImportCommand(inputPath, run, fallbackMessage) { + const inputBytes = await readBinaryInput(inputPath); + const result = await run(inputBytes); + if (!result.ok) { + throw new CliCommandFailure(result, fallbackMessage); + } + return result; +} + +async function runDecodedTextImportCommand(inputPath, run, decode, decodeFailureMessage) { + const inputText = await readDecodedTextInput(inputPath, decode, decodeFailureMessage); + return run(inputText); +} + +async function runEncodedImportCommand(inputPath, outPath, api, to, run) { + const result = await run(inputPath); + return outPath ? encodeOutputForTarget(result, outPath, api, to) : result; +} + +async function runAbcToSvgRenderCommand(inputPath, api) { + const inputText = await readTextInput(inputPath); + const imported = api.abc.importToMusicXml(inputText); + if (!imported.ok || typeof imported.output !== "string") { + throw new CliCommandFailure(imported, "ABC to MusicXML conversion failed."); + } + const rendered = await api.render.svgFromMusicXml(imported.output); + if (!rendered.ok) { + throw new CliCommandFailure(rendered, "SVG render failed."); + } + return { + ...rendered, + warnings: [...imported.warnings, ...rendered.warnings], + diagnostics: [...imported.diagnostics, ...rendered.diagnostics], + stages: [ + buildStageDiagnostics("abc_to_musicxml", imported), + buildStageDiagnostics("musicxml_to_svg", rendered), + ], + }; +} + +function buildStageDiagnostics(name, result) { + return { + name, + status: result.diagnostics.length > 0 ? "warning" : "success", + warning_count: result.warnings.length, + error_count: result.diagnostics.length, + }; +} + async function encodeOutputForTarget(result, outPath, api, to) { if (!result.ok) return result; if (to === "musicxml" && typeof result.output === "string") { @@ -479,6 +551,15 @@ async function readTextInput(inputPath) { return Buffer.from(bytes).toString("utf8"); } +async function readDecodedTextInput(inputPath, decode, fallbackMessage) { + const inputBytes = await readBinaryInput(inputPath); + const decoded = await decode(inputBytes, inputPath); + if (!decoded.ok || typeof decoded.output !== "string") { + throw new CliCommandFailure(decoded, fallbackMessage); + } + return decoded.output; +} + async function readBinaryInput(inputPath) { if (inputPath && inputPath !== "-") { return fs.readFileSync(path.resolve(inputPath)); @@ -554,23 +635,39 @@ function detectRequestedDiagnosticsFormat(argv) { return "text"; } -function buildSuccessDiagnostics(command, options, result) { - const warnings = Array.isArray(result.warnings) ? result.warnings : []; - const errors = Array.isArray(result.diagnostics) ? result.diagnostics : []; +function summarizeDiagnosticOutcome(warnings, errors) { const status = errors.length > 0 ? "error" : warnings.length > 0 ? "warning" : "success"; - const diagnostics = { - ok: result.ok && errors.length === 0, - diagnostics_version: DIAGNOSTICS_VERSION, - command: command.join(" "), - context: command.join(" "), + return { status, + ok: errors.length === 0, exit_code: status === "error" ? 1 : 0, warning_count: warnings.length, error_count: errors.length, - io: buildIoDiagnostics(options), + }; +} + +function buildBaseDiagnostics(command, io, warnings, errors) { + const outcome = summarizeDiagnosticOutcome(warnings, errors); + return { + ok: outcome.ok, + diagnostics_version: DIAGNOSTICS_VERSION, + command, + context: command, + status: outcome.status, + exit_code: outcome.exit_code, + warning_count: outcome.warning_count, + error_count: outcome.error_count, + io, warnings, errors, }; +} + +function buildSuccessDiagnostics(command, options, result) { + const warnings = Array.isArray(result.warnings) ? result.warnings : []; + const errors = Array.isArray(result.diagnostics) ? result.diagnostics : []; + const diagnostics = buildBaseDiagnostics(command.join(" "), buildIoDiagnostics(options), warnings, errors); + diagnostics.ok = result.ok && diagnostics.ok; if (Array.isArray(result.stages) && result.stages.length > 0) { diagnostics.stages = result.stages; } @@ -578,39 +675,37 @@ function buildSuccessDiagnostics(command, options, result) { } function buildErrorDiagnostics(argv, error, exitCode) { - const command = summarizeCommandFromArgv(argv); + const argvSummary = summarizeArgv(argv); const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - diagnostics_version: DIAGNOSTICS_VERSION, - command, - context: command, - status: "error", - exit_code: exitCode, - warning_count: 0, - error_count: 1, - io: buildIoDiagnosticsFromArgv(argv), - error_type: error instanceof CliUsageError ? "usage_error" : "processing_error", - error_code: typeof error?.code === "string" ? error.code : "processing_error", - error_details: error?.details, - warnings: [], - errors: [message], - }; + const diagnostics = buildBaseDiagnostics(argvSummary.command, buildIoDiagnostics(argvSummary.options), [], [message]); + diagnostics.ok = false; + diagnostics.exit_code = exitCode; + diagnostics.error_type = error instanceof CliUsageError ? "usage_error" : "processing_error"; + diagnostics.error_code = typeof error?.code === "string" ? error.code : "processing_error"; + diagnostics.error_details = error?.details; + return diagnostics; } -function summarizeCommandFromArgv(argv) { +function summarizeArgv(argv) { const command = []; + const options = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (!token.startsWith("--")) { command.push(token); continue; } - if (token !== "--help") { - index += 1; + const key = token.slice(2); + if (key === "help") { + continue; } + options[key] = argv[index + 1]; + index += 1; } - return command.join(" ") || "cli"; + return { + command: command.join(" ") || "cli", + options, + }; } function buildIoDiagnostics(options) { @@ -620,19 +715,6 @@ function buildIoDiagnostics(options) { }; } -function buildIoDiagnosticsFromArgv(argv) { - const options = {}; - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token.startsWith("--")) continue; - const key = token.slice(2); - if (key === "help") continue; - options[key] = argv[index + 1]; - index += 1; - } - return buildIoDiagnostics(options); -} - function buildInputListFromOptions(options) { const inputs = []; if ("in" in options) { From 35b34774ae85a7731d5242e0bc3351c2b1f223ed Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Sat, 18 Apr 2026 06:18:36 +0900 Subject: [PATCH 6/8] =?UTF-8?q?CLI=20=E4=BA=92=E6=8F=9B=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=E3=82=92=20upstream=20=E3=81=AB=E5=8F=96=E3=82=8A=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=80=81landing=20page=20=E6=96=87=E8=A8=80=E3=81=A8?= =?UTF-8?q?=20ZIP/selector=20=E5=9B=9E=E5=B8=B0=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 CLI 周辺の downstream 互換対応の一部を upstream 側に取り込み、あわせて landing page の CLI 文言と関連テストを更新しています。 ## 変更内容 - `src/ts/cli-api.ts` - indexed measure-note builder に残っていた `Array.prototype.flatMap` の使用を通常ループに置き換えました。 - `IndexedMeasureNote[]` の構築方法を整理し、既存の selector 解決処理と組み合わせる形を維持しています。 - `tests/unit/mikuscore-cli.spec.ts` - `state validate-command` の失敗系に対する回帰テストを追加しました。 - 追加した主なケース: - selector が複数ノートに一致する場合 - selector が object ではない不正 payload の場合 - `tests/unit/cli-api.spec.ts` - ZIP helper I/O に対する軽量 roundtrip テストを追加しました。 - 追加した主なケース: - `MusicXML -> .mxl -> MusicXML` - `MusicXML -> .mscz -> MuseScore text -> MusicXML` - `index-src.html` - landing page に残っていた `convert-first` という表現を、現在の CLI 構成に合わせた文言へ更新しました。 - 英語・日本語の両方を更新しています。 - `index.html` - `index-src.html` の変更を反映した生成物更新です。 - コミット上では `Updated:` の日付表示も更新されています。 - `TODO.md` - `src/ts/cli-api.ts` まわりの upstream 取り込み方針を追記しました。 - MIDI export option は当面 CLI flag としては公開せず、内部固定のまま扱う方針を追記しました。 - landing page の CLI 文言を current-facing な構成へ揃える TODO を追記しました。 ## 目的 - downstream 側で持っていた CLI 互換対応の一部を upstream 側へ寄せること - `ES2018` 前提の isolated bundle path と衝突しうる `flatMap` 依存を避けること - current-facing な CLI 文言を `convert` / `render` / `state` に揃えること - selector 解決と ZIP helper I/O の回帰検知を強めること --- TODO.md | 16 +++++++++++++- index-src.html | 4 ++-- index.html | 6 +++--- src/ts/cli-api.ts | 16 ++++++++------ tests/unit/cli-api.spec.ts | 31 ++++++++++++++++++++++++++ tests/unit/mikuscore-cli.spec.ts | 37 ++++++++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index 9fdc238..f799e4f 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,13 @@ ## CLI +- [ ] Upstream the remaining downstream compatibility adjustments around `src/ts/cli-api.ts`. + - Scope: + - stabilize CLI selector resolution behavior so downstream-specific guard code is no longer needed + - remove `Array.prototype.flatMap` usage from indexed measure-note building so the current `ES2018`-based isolated bundle path remains compatible + - Expected follow-up: + - add focused regression coverage for selector resolution edge cases + - [x] Document `convert`-first CLI naming consistently in all current-facing docs. - Recheck `README.md`, `docs/spec/CLI_STEP1.md`, and future notes after the command surface stabilizes. - Keep `import/export` as internal facade wording only, not CLI wording. @@ -24,7 +31,8 @@ - `mikuscore convert --from midi --to musicxml` - `mikuscore convert --from musicxml --to midi` - Next checks: - - decide whether CLI needs MIDI export options such as profile / metadata toggles + - keep MIDI export options internal for now; do not expose CLI flags yet + - revisit CLI-level MIDI export options such as profile / metadata toggles only after the current fixed defaults prove insufficient - [x] Implement Step 3 conversion/render pairs. - Current first cut exists for: @@ -208,6 +216,12 @@ ## Build +- [ ] Keep landing page CLI wording aligned with the current `convert` / `render` / `state` command split. + - Current source of truth: + - `index-src.html` + - generated `index.html` + - Remove stale `convert-first` wording from current-facing landing-page copy. + - [ ] Shorten and stabilize `npm run build:full`. - Current observation: - `typecheck` and `build:dist` are relatively small, but `test:build:full` dominates total time diff --git a/index-src.html b/index-src.html index 5e27ec4..a36b309 100644 --- a/index-src.html +++ b/index-src.html @@ -140,7 +140,7 @@

English

Supported formats: MusicXML (`.musicxml` / `.xml` / `.mxl`), MuseScore (`.mscx` / `.mscz`), MIDI (`.mid` / `.midi`), VSQX (`.vsqx`), ABC (`.abc`), MEI (`.mei`, experimental), LilyPond (`.ly`, experimental).

- A convert-first CLI is also available for scripted workflows. Related projects include + A CLI is also available for scripted workflows, centered on `convert`, `render`, and `state`. Related projects include mikuscore-skills, which embeds mikuscore into generative-AI workflows, and miku-abc-player, which makes an ABC-limited subset of mikuscore easier to use.

@@ -174,7 +174,7 @@

日本語

対応フォーマット: MusicXML(`.musicxml` / `.xml` / `.mxl`)、MuseScore(`.mscx` / `.mscz`)、MIDI(`.mid` / `.midi`)、VSQX(`.vsqx`)、ABC(`.abc`)、MEI(`.mei`、実験的対応)、LilyPond(`.ly`、実験的対応)。

- スクリプト利用向けには convert-first の CLI もあります。関連プロダクトとして、 + スクリプト利用向けには `convert` / `render` / `state` を中心にした CLI もあります。関連プロダクトとして、 mikuscore-skills は mikuscore を生成 AI ワークフローに組み込むための agent skills、 miku-abc-player は mikuscore の機能を ABC に限定して使いやすくした companion web app です。

diff --git a/index.html b/index.html index 1bec647..eef7936 100644 --- a/index.html +++ b/index.html @@ -125,7 +125,7 @@

mikuscore

-

Updated: 2026-04-17

+

Updated: 2026-04-18

English

@@ -140,7 +140,7 @@

English

Supported formats: MusicXML (`.musicxml` / `.xml` / `.mxl`), MuseScore (`.mscx` / `.mscz`), MIDI (`.mid` / `.midi`), VSQX (`.vsqx`), ABC (`.abc`), MEI (`.mei`, experimental), LilyPond (`.ly`, experimental).

- A convert-first CLI is also available for scripted workflows. Related projects include + A CLI is also available for scripted workflows, centered on `convert`, `render`, and `state`. Related projects include mikuscore-skills, which embeds mikuscore into generative-AI workflows, and miku-abc-player, which makes an ABC-limited subset of mikuscore easier to use.

@@ -174,7 +174,7 @@

日本語

対応フォーマット: MusicXML(`.musicxml` / `.xml` / `.mxl`)、MuseScore(`.mscx` / `.mscz`)、MIDI(`.mid` / `.midi`)、VSQX(`.vsqx`)、ABC(`.abc`)、MEI(`.mei`、実験的対応)、LilyPond(`.ly`、実験的対応)。

- スクリプト利用向けには convert-first の CLI もあります。関連プロダクトとして、 + スクリプト利用向けには `convert` / `render` / `state` を中心にした CLI もあります。関連プロダクトとして、 mikuscore-skills は mikuscore を生成 AI ワークフローに組み込むための agent skills、 miku-abc-player は mikuscore の機能を ABC に限定して使いやすくした companion web app です。

diff --git a/src/ts/cli-api.ts b/src/ts/cli-api.ts index 3a92401..26e5585 100644 --- a/src/ts/cli-api.ts +++ b/src/ts/cli-api.ts @@ -110,19 +110,20 @@ const buildIndexedMeasureNotes = (xmlText: string): IndexedMeasureNote[] => { return `n${sequence}`; }); - return Array.from(doc.querySelectorAll("score-partwise > part > measure")).flatMap((measure) => { + const indexedNotes: IndexedMeasureNote[] = []; + for (const measure of Array.from(doc.querySelectorAll("score-partwise > part > measure"))) { const part = measure.parentElement; const partId = part?.getAttribute("id")?.trim() ?? null; const measureNumber = measure.getAttribute("number")?.trim() ?? ""; const voiceNoteCounts = new Map(); - return Array.from(measure.querySelectorAll(":scope > note")).flatMap((note, noteIndex) => { + for (const [noteIndex, note] of Array.from(measure.querySelectorAll(":scope > note")).entries()) { const nodeId = nodeToId.get(note); - if (!nodeId) return []; + if (!nodeId) continue; const voice = getVoiceText(note); const voiceKey = voice ?? "__none__"; const nextVoiceNoteIndex = (voiceNoteCounts.get(voiceKey) ?? 0) + 1; voiceNoteCounts.set(voiceKey, nextVoiceNoteIndex); - return [{ + indexedNotes.push({ nodeId, selector: { part_id: partId, @@ -131,9 +132,10 @@ const buildIndexedMeasureNotes = (xmlText: string): IndexedMeasureNote[] => { voice, voice_note_index: nextVoiceNoteIndex, }, - }]; - }); - }); + }); + } + } + return indexedNotes; }; const resolveMeasureNoteSelector = ( diff --git a/tests/unit/cli-api.spec.ts b/tests/unit/cli-api.spec.ts index c78ee6f..18ff4d5 100644 --- a/tests/unit/cli-api.spec.ts +++ b/tests/unit/cli-api.spec.ts @@ -154,6 +154,37 @@ describe("cli-api", () => { expect(extracted).toContain(""); expect(extracted).toContain("\n "); }); + + it("roundtrips MusicXML through .mxl helper I/O", async () => { + const encoded = await encodeCliMusicXmlOutput(validMusicXml("Roundtrip MXL"), "score.mxl"); + expect(encoded.ok).toBe(true); + if (!encoded.ok || typeof encoded.output === "string") return; + + const decoded = await decodeCliMusicXmlInput(encoded.output, "score.mxl"); + expect(decoded.ok).toBe(true); + if (!decoded.ok || typeof decoded.output !== "string") return; + expect(decoded.output).toContain("Roundtrip MXL"); + }); + + it("roundtrips MusicXML through .mscz helper I/O via MuseScore facade", async () => { + const exported = exportMusicXmlToMuseScore(validMusicXml("Roundtrip MSCZ")); + expect(exported.ok).toBe(true); + if (!exported.ok || typeof exported.output !== "string") return; + + const encoded = await encodeCliMuseScoreOutput(exported.output, "score.mscz"); + expect(encoded.ok).toBe(true); + if (!encoded.ok || typeof encoded.output === "string") return; + + const decoded = await decodeCliMuseScoreInput(encoded.output, "score.mscz"); + expect(decoded.ok).toBe(true); + if (!decoded.ok || typeof decoded.output !== "string") return; + + const imported = importMuseScoreToMusicXml(decoded.output); + expect(imported.ok).toBe(true); + if (!imported.ok || typeof imported.output !== "string") return; + expect(imported.output).toContain("Roundtrip MSCZ"); + }); }); function buildSimpleMidi() { diff --git a/tests/unit/mikuscore-cli.spec.ts b/tests/unit/mikuscore-cli.spec.ts index 6e74cd1..f0102c8 100644 --- a/tests/unit/mikuscore-cli.spec.ts +++ b/tests/unit/mikuscore-cli.spec.ts @@ -445,6 +445,39 @@ describe("mikuscore cli", () => { input: validEditableMusicXml("Bad selector"), } ); + const ambiguousSelector = runCli( + [ + "state", + "validate-command", + "--command", + JSON.stringify({ + type: "change_to_pitch", + selector: { + part_id: "P1", + measure_number: "1", + }, + pitch: { step: "G", octave: 4 }, + }), + ], + { + input: validEditableMusicXml("Ambiguous selector"), + } + ); + const invalidSelectorPayload = runCli( + [ + "state", + "validate-command", + "--command", + JSON.stringify({ + type: "change_to_pitch", + selector: "n1", + pitch: { step: "G", octave: 4 }, + }), + ], + { + input: validEditableMusicXml("Invalid selector payload"), + } + ); const missingMeasureOption = runCli(["state", "inspect-measure"], { input: validEditableMusicXml("Missing measure"), }); @@ -466,6 +499,10 @@ describe("mikuscore cli", () => { expect(missingCommandPayload.stderr).toContain("requires exactly one of --command"); expect(unresolvedSelector.status).toBe(1); expect(unresolvedSelector.stderr).toContain("Failed to resolve CLI command selector"); + expect(ambiguousSelector.status).toBe(1); + expect(ambiguousSelector.stderr).toContain("matched multiple notes"); + expect(invalidSelectorPayload.status).toBe(1); + expect(invalidSelectorPayload.stderr).toContain("selector must be an object"); expect(missingMeasureOption.status).toBe(2); expect(missingMeasureOption.stderr).toContain("requires --measure"); expect(missingDiffInputs.status).toBe(2); From 22ca78f493de91ea28b7b1644da8e3c6113825b2 Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Sat, 18 Apr 2026 06:31:38 +0900 Subject: [PATCH 7/8] =?UTF-8?q?CLI=20API=20=E3=81=AE=20selector=20?= =?UTF-8?q?=E6=AD=A3=E8=A6=8F=E5=8C=96=E3=81=BE=E3=82=8F=E3=82=8A=E3=81=AB?= =?UTF-8?q?=E5=9E=8B=E3=82=AC=E3=83=BC=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 `src/ts/cli-api.ts` において、CLI command selector の解決結果および command 正規化結果に対する型ガードを追加し、失敗時の `message` 参照を型安全にしました。 ## 変更内容 - `resolveMeasureNoteSelector(...)` の戻り値型に `ResolvedMeasureNoteSelectorResult` を追加 - `ResolvedMeasureNoteSelectorResult` 用の型ガード `isResolvedMeasureNoteSelectorFailure(...)` を追加 - `CliCommandNormalizationResult` 用の型ガード `isCliCommandNormalizationFailure(...)` を追加 - `if (!resolved.ok)` を `isResolvedMeasureNoteSelectorFailure(resolved)` に置き換え - `if (!normalized.ok)` を `isCliCommandNormalizationFailure(normalized)` に置き換え ## 補足 - 変更対象は `src/ts/cli-api.ts` のみです - 失敗分岐の型絞り込みを明示するための修正です --- src/ts/cli-api.ts | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/ts/cli-api.ts b/src/ts/cli-api.ts index 26e5585..687f3e4 100644 --- a/src/ts/cli-api.ts +++ b/src/ts/cli-api.ts @@ -100,6 +100,29 @@ type CliCommandNormalizationResult = message: string; }; +type ResolvedMeasureNoteSelectorResult = + | { + ok: true; + nodeId: string; + voice?: string | null; + } + | { + ok: false; + message: string; + }; + +const isResolvedMeasureNoteSelectorFailure = ( + result: ResolvedMeasureNoteSelectorResult +): result is { ok: false; message: string } => { + return result.ok === false; +}; + +const isCliCommandNormalizationFailure = ( + result: CliCommandNormalizationResult +): result is { ok: false; message: string } => { + return result.ok === false; +}; + const buildIndexedMeasureNotes = (xmlText: string): IndexedMeasureNote[] => { const doc = parseCoreXml(xmlText); const nodeToId = new WeakMap(); @@ -142,7 +165,7 @@ const resolveMeasureNoteSelector = ( selector: MeasureNoteSelector | undefined, indexedNotes: IndexedMeasureNote[], selectorName: string -): { ok: true; nodeId: string; voice?: string | null } | { ok: false; message: string } => { +): ResolvedMeasureNoteSelectorResult => { if (!selector || typeof selector !== "object") { return { ok: false, @@ -198,7 +221,7 @@ const normalizeCliCommandSelectors = (xmlText: string, command: CoreCommand): Cl if ("selector" in nextCommand && !("targetNodeId" in nextCommand)) { const resolved = resolveMeasureNoteSelector(nextCommand.selector as MeasureNoteSelector | undefined, indexedNotes, "selector"); - if (!resolved.ok) { + if (isResolvedMeasureNoteSelectorFailure(resolved)) { return { ok: false, message: `Failed to resolve CLI command selector: ${resolved.message}`, @@ -216,7 +239,7 @@ const normalizeCliCommandSelectors = (xmlText: string, command: CoreCommand): Cl indexedNotes, "anchor_selector" ); - if (!resolved.ok) { + if (isResolvedMeasureNoteSelectorFailure(resolved)) { return { ok: false, message: `Failed to resolve CLI command selector: ${resolved.message}`, @@ -549,7 +572,7 @@ export const summarizeMusicXmlState = (xmlText: string): CliResult => { export const validateMusicXmlCommand = (xmlText: string, command: CoreCommand): CliResult => { try { const normalized = normalizeCliCommandSelectors(xmlText, command); - if (!normalized.ok) return failureResult(normalized.message); + if (isCliCommandNormalizationFailure(normalized)) return failureResult(normalized.message); const core = new ScoreCore(); core.load(xmlText); const result = core.dispatch(normalized.command); @@ -583,7 +606,7 @@ export const validateMusicXmlCommand = (xmlText: string, command: CoreCommand): export const applyMusicXmlCommand = (xmlText: string, command: CoreCommand): CliResult => { try { const normalized = normalizeCliCommandSelectors(xmlText, command); - if (!normalized.ok) return failureResult(normalized.message); + if (isCliCommandNormalizationFailure(normalized)) return failureResult(normalized.message); const core = new ScoreCore(); core.load(xmlText); const result = core.dispatch(normalized.command); From 5a014ce0ac7a3d1341be87857fd31a188dee8cad Mon Sep 17 00:00:00 2001 From: Toshiki Iga Date: Sat, 18 Apr 2026 07:34:20 +0900 Subject: [PATCH 8/8] =?UTF-8?q?CLI=20=E3=81=AB=20`abc=20->=20midi`=20/=20`?= =?UTF-8?q?MEI`=20/=20`LilyPond`=20=E3=81=AE=20MusicXML=20=E7=9B=B8?= =?UTF-8?q?=E4=BA=92=E5=A4=89=E6=8F=9B=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97?= =?UTF-8?q?=E3=80=81=E9=96=A2=E9=80=A3=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=A8=20TODO=20=E3=82=92=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 CLI の変換面を拡張し、既存の reusable な変換ロジックを `cli-api` と `mikuscore-cli` に接続しました。あわせて、CLI help・回帰テスト・関連ドキュメント・TODO を更新しています。 ## 変更内容 - `abc -> midi` の CLI 変換を追加 - `mei <-> musicxml` の CLI 変換を追加 - `lilypond <-> musicxml` の CLI 変換を追加 - `src/ts/cli-api.ts` に `mei` / `lilypond` の facade を追加 - `scripts/mikuscore-cli.mjs` の help と convert handler を更新 - `tests/unit/mikuscore-cli.spec.ts` に成功ケース・失敗ケースの回帰テストを追加 - `README.md` / `docs/DEVELOPMENT.md` / `docs/future/CLI_ROADMAP.md` の CLI 記述を更新 - `TODO.md` に CLI surface sync、VSQX の外部依存対応、MEI / LilyPond の CLI 対応タスクを追記 ## 変更ファイル - `src/ts/cli-api.ts` - `scripts/mikuscore-cli.mjs` - `tests/unit/mikuscore-cli.spec.ts` - `README.md` - `docs/DEVELOPMENT.md` - `docs/future/CLI_ROADMAP.md` - `TODO.md` ## 補足 - VSQX の CLI 対応はこの変更には含めていません。 - 理由として、`TODO.md` に integration / upstream 側への変更依頼が必要な前提を追記しています。 --- README.md | 4 ++ TODO.md | 35 ++++++++++ docs/DEVELOPMENT.md | 8 +++ docs/future/CLI_ROADMAP.md | 4 ++ scripts/mikuscore-cli.mjs | 68 ++++++++++++++++++++ src/ts/cli-api.ts | 96 ++++++++++++++++++++++++++++ tests/unit/mikuscore-cli.spec.ts | 106 ++++++++++++++++++++++++++++++- 7 files changed, 320 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fcef45b..73cfb82 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ Examples: - `npm run cli -- convert --from musicxml --to abc --in score.musicxml --out score.abc` - `npm run cli -- convert --from midi --to musicxml --in score.mid --out score.musicxml` - `npm run cli -- convert --from musicxml --to midi --in score.musicxml --out score.mid` +- `npm run cli -- convert --from mei --to musicxml --in score.mei --out score.musicxml` +- `npm run cli -- convert --from musicxml --to mei --in score.musicxml --out score.mei` +- `npm run cli -- convert --from lilypond --to musicxml --in score.ly --out score.musicxml` +- `npm run cli -- convert --from musicxml --to lilypond --in score.musicxml --out score.ly` - `npm run cli -- convert --from musescore --to musicxml --in score.mscx --out score.musicxml` - `npm run cli -- convert --from musicxml --to musescore --in score.musicxml --out score.mscx` - `npm run cli -- convert --from musicxml --to abc --in score.mxl --out score.abc` diff --git a/TODO.md b/TODO.md index f799e4f..ab0da4b 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,13 @@ ## CLI +- [ ] Add a CLI surface sync check whenever `src/ts/cli-api.ts` grows new or newly composable entry points. + - Scope: + - verify command/help/test coverage stays aligned across `src/ts/cli-api.ts`, `scripts/mikuscore-cli.mjs`, and `tests/unit/mikuscore-cli.spec.ts` + - explicitly review newly composable one-shot routes such as `abc -> midi`, not only direct one-function facade additions + - Expected follow-up: + - add a lightweight maintenance checklist or coverage table so CLI-exposed routes do not get missed during future facade expansion + - [ ] Upstream the remaining downstream compatibility adjustments around `src/ts/cli-api.ts`. - Scope: - stabilize CLI selector resolution behavior so downstream-specific guard code is no longer needed @@ -34,6 +41,34 @@ - keep MIDI export options internal for now; do not expose CLI flags yet - revisit CLI-level MIDI export options such as profile / metadata toggles only after the current fixed defaults prove insufficient +- [ ] Prepare VSQX CLI support by requesting upstream/integration-side changes to the vendored bridge first. + - Current blocker: + - current `vsqx` support depends on the vendored `utaformatix3-ts-plus` browser-oriented bridge shape, so the existing CLI cannot call it as a normal non-UI facade + - Required external ask: + - request a non-browser callable entrypoint or equivalent runtime shape from the integration/upstream side before wiring `musicxml <-> vsqx` into the CLI + - Intended follow-up after that lands: + - add `mikuscore convert --from vsqx --to musicxml` + - add `mikuscore convert --from musicxml --to vsqx` + - add matching CLI help and regression tests + +- [ ] Add MEI CLI conversion pairs around the existing reusable format I/O. + - Target pairs: + - `mikuscore convert --from mei --to musicxml` + - `mikuscore convert --from musicxml --to mei` + - Implementation slices: + - extend `src/ts/cli-api.ts` with `mei` facade entries + - wire `scripts/mikuscore-cli.mjs` help and convert handlers + - add CLI regression tests for stdin/file input, `--out`, and representative failures + +- [ ] Add LilyPond CLI conversion pairs around the existing reusable format I/O. + - Target pairs: + - `mikuscore convert --from lilypond --to musicxml` + - `mikuscore convert --from musicxml --to lilypond` + - Implementation slices: + - extend `src/ts/cli-api.ts` with `lilypond` facade entries + - wire `scripts/mikuscore-cli.mjs` help and convert handlers + - add CLI regression tests for stdin/file input, `--out`, and representative failures + - [x] Implement Step 3 conversion/render pairs. - Current first cut exists for: - `mikuscore convert --from musescore --to musicxml` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ef0317e..5ee7343 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -42,6 +42,10 @@ Available commands: - `mikuscore convert --from musicxml --to abc` - `mikuscore convert --from midi --to musicxml` - `mikuscore convert --from musicxml --to midi` +- `mikuscore convert --from mei --to musicxml` +- `mikuscore convert --from musicxml --to mei` +- `mikuscore convert --from lilypond --to musicxml` +- `mikuscore convert --from musicxml --to lilypond` - `mikuscore convert --from musescore --to musicxml` - `mikuscore convert --from musicxml --to musescore` - `mikuscore render svg` @@ -85,6 +89,10 @@ Examples: - `npm run cli -- convert --from musicxml --to abc --in score.musicxml --out score.abc` - `npm run cli -- convert --from midi --to musicxml --in score.mid --out score.musicxml` - `npm run cli -- convert --from musicxml --to midi --in score.musicxml --out score.mid` +- `npm run cli -- convert --from mei --to musicxml --in score.mei --out score.musicxml` +- `npm run cli -- convert --from musicxml --to mei --in score.musicxml --out score.mei` +- `npm run cli -- convert --from lilypond --to musicxml --in score.ly --out score.musicxml` +- `npm run cli -- convert --from musicxml --to lilypond --in score.musicxml --out score.ly` - `npm run cli -- convert --from musescore --to musicxml --in score.mscx --out score.musicxml` - `npm run cli -- convert --from musicxml --to musescore --in score.musicxml --out score.mscx` - `npm run cli -- convert --from musicxml --to abc --in score.mxl --out score.abc` diff --git a/docs/future/CLI_ROADMAP.md b/docs/future/CLI_ROADMAP.md index 79ae790..9e68ae7 100644 --- a/docs/future/CLI_ROADMAP.md +++ b/docs/future/CLI_ROADMAP.md @@ -29,6 +29,10 @@ Current implemented Step 1 scope: - `mikuscore convert --from musicxml --to abc` - `mikuscore convert --from midi --to musicxml` - `mikuscore convert --from musicxml --to midi` +- `mikuscore convert --from mei --to musicxml` +- `mikuscore convert --from musicxml --to mei` +- `mikuscore convert --from lilypond --to musicxml` +- `mikuscore convert --from musicxml --to lilypond` - `mikuscore convert --from musescore --to musicxml` - `mikuscore convert --from musicxml --to musescore` - `mikuscore render svg` diff --git a/scripts/mikuscore-cli.mjs b/scripts/mikuscore-cli.mjs index 9a38e0b..9d4f1a3 100644 --- a/scripts/mikuscore-cli.mjs +++ b/scripts/mikuscore-cli.mjs @@ -15,9 +15,14 @@ const HELP_TEXT = { top: [ "Usage:", " mikuscore convert --from abc --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from abc --to midi [--in |-] [--out |-] [--diagnostics text|json]", " mikuscore convert --from musicxml --to abc [--in |-] [--out |-] [--diagnostics text|json]", " mikuscore convert --from midi --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", " mikuscore convert --from musicxml --to midi [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from mei --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musicxml --to mei [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from lilypond --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musicxml --to lilypond [--in |-] [--out |-] [--diagnostics text|json]", " mikuscore convert --from musescore --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", " mikuscore convert --from musicxml --to musescore [--in |-] [--out |-] [--diagnostics text|json]", " mikuscore render svg [--in |-] [--out |-] [--diagnostics text|json]", @@ -47,7 +52,12 @@ const HELP_TEXT = { convert: [ "Usage:", " mikuscore convert --from abc --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from abc --to midi [--in |-] [--out |-] [--diagnostics text|json]", " mikuscore convert --from musicxml --to abc [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from mei --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musicxml --to mei [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from lilypond --to musicxml [--in |-] [--out |-] [--diagnostics text|json]", + " mikuscore convert --from musicxml --to lilypond [--in |-] [--out |-] [--diagnostics text|json]", " mikuscore convert --help", "", "Description:", @@ -55,9 +65,14 @@ const HELP_TEXT = { "", "Supported pairs:", " --from abc --to musicxml", + " --from abc --to midi", " --from musicxml --to abc", " --from midi --to musicxml", " --from musicxml --to midi", + " --from mei --to musicxml", + " --from musicxml --to mei", + " --from lilypond --to musicxml", + " --from musicxml --to lilypond", " --from musescore --to musicxml", " --from musicxml --to musescore", "", @@ -328,6 +343,8 @@ function buildConvertHandlers(options, api) { "ABC to MusicXML conversion failed." ) ), + "abc:midi": async () => + runAbcToMidiConvertCommand(options.in, api), "musicxml:abc": async () => runMusicXmlExportCommand( options.in, @@ -350,6 +367,36 @@ function buildConvertHandlers(options, api) { (inputText) => api.midi.exportFromMusicXml(inputText), "MusicXML to MIDI conversion failed." ), + "mei:musicxml": async () => + runEncodedImportCommand(options.in, options.out, api, to, (inputPath) => + runTextImportCommand( + inputPath, + (inputText) => api.mei.importToMusicXml(inputText), + "MEI to MusicXML conversion failed." + ) + ), + "musicxml:mei": async () => + runMusicXmlExportCommand( + options.in, + api, + (inputText) => api.mei.exportFromMusicXml(inputText), + "MusicXML to MEI conversion failed." + ), + "lilypond:musicxml": async () => + runEncodedImportCommand(options.in, options.out, api, to, (inputPath) => + runTextImportCommand( + inputPath, + (inputText) => api.lilypond.importToMusicXml(inputText), + "LilyPond to MusicXML conversion failed." + ) + ), + "musicxml:lilypond": async () => + runMusicXmlExportCommand( + options.in, + api, + (inputText) => api.lilypond.exportFromMusicXml(inputText), + "MusicXML to LilyPond conversion failed." + ), "musescore:musicxml": async () => runEncodedImportCommand(options.in, options.out, api, to, async (inputPath) => { const result = await runDecodedTextImportCommand( @@ -526,6 +573,27 @@ async function runAbcToSvgRenderCommand(inputPath, api) { }; } +async function runAbcToMidiConvertCommand(inputPath, api) { + const inputText = await readTextInput(inputPath); + const imported = api.abc.importToMusicXml(inputText); + if (!imported.ok || typeof imported.output !== "string") { + throw new CliCommandFailure(imported, "ABC to MusicXML conversion failed."); + } + const exported = api.midi.exportFromMusicXml(imported.output); + if (!exported.ok) { + throw new CliCommandFailure(exported, "MusicXML to MIDI conversion failed."); + } + return { + ...exported, + warnings: [...imported.warnings, ...exported.warnings], + diagnostics: [...imported.diagnostics, ...exported.diagnostics], + stages: [ + buildStageDiagnostics("abc_to_musicxml", imported), + buildStageDiagnostics("musicxml_to_midi", exported), + ], + }; +} + function buildStageDiagnostics(name, result) { return { name, diff --git a/src/ts/cli-api.ts b/src/ts/cli-api.ts index 687f3e4..2f34b50 100644 --- a/src/ts/cli-api.ts +++ b/src/ts/cli-api.ts @@ -4,6 +4,8 @@ */ import { convertAbcToMusicXml, exportMusicXmlDomToAbc } from "./abc-io"; +import { convertLilyPondToMusicXml, exportMusicXmlDomToLilyPond } from "./lilypond-io"; +import { convertMeiToMusicXml, exportMusicXmlDomToMei } from "./mei-io"; import { buildMidiBytesForPlayback, buildPlaybackEventsFromMusicXmlDoc, @@ -454,6 +456,92 @@ export const importMuseScoreToMusicXml = (musescoreText: string): CliResult => { } }; +export const importMeiToMusicXml = (meiText: string): CliResult => { + try { + return { + ok: true, + output: normalizeImportedMusicXmlText(convertMeiToMusicXml(meiText)), + warnings: [], + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to parse MEI: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + +export const exportMusicXmlToMei = (xmlText: string): CliResult => { + const doc = parseMusicXmlDocument(xmlText); + if (!doc) { + return { + ok: false, + warnings: [], + diagnostics: ["Failed to parse MusicXML: input is not a valid MusicXML document."], + }; + } + + try { + return { + ok: true, + output: exportMusicXmlDomToMei(doc), + warnings: [], + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to export MEI: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + +export const importLilyPondToMusicXml = (lilypondText: string): CliResult => { + try { + return { + ok: true, + output: normalizeImportedMusicXmlText(convertLilyPondToMusicXml(lilypondText)), + warnings: [], + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to parse LilyPond: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + +export const exportMusicXmlToLilyPond = (xmlText: string): CliResult => { + const doc = parseMusicXmlDocument(xmlText); + if (!doc) { + return { + ok: false, + warnings: [], + diagnostics: ["Failed to parse MusicXML: input is not a valid MusicXML document."], + }; + } + + try { + return { + ok: true, + output: exportMusicXmlDomToLilyPond(doc), + warnings: [], + diagnostics: [], + }; + } catch (error) { + return { + ok: false, + warnings: [], + diagnostics: [`Failed to export LilyPond: ${error instanceof Error ? error.message : String(error)}`], + }; + } +}; + export const exportMusicXmlToMuseScore = (xmlText: string): CliResult => { const doc = parseMusicXmlDocument(xmlText); if (!doc) { @@ -857,6 +945,14 @@ export const cliApi = { importToMusicXml: importMidiToMusicXml, exportFromMusicXml: exportMusicXmlToMidi, }, + mei: { + importToMusicXml: importMeiToMusicXml, + exportFromMusicXml: exportMusicXmlToMei, + }, + lilypond: { + importToMusicXml: importLilyPondToMusicXml, + exportFromMusicXml: exportMusicXmlToLilyPond, + }, musescore: { importToMusicXml: importMuseScoreToMusicXml, exportFromMusicXml: exportMusicXmlToMuseScore, diff --git a/tests/unit/mikuscore-cli.spec.ts b/tests/unit/mikuscore-cli.spec.ts index f0102c8..5533b48 100644 --- a/tests/unit/mikuscore-cli.spec.ts +++ b/tests/unit/mikuscore-cli.spec.ts @@ -34,7 +34,10 @@ describe("mikuscore cli", () => { expect(topLevel.status).toBe(0); expect(topLevel.stdout).toContain("mikuscore convert --from abc --to musicxml"); + expect(topLevel.stdout).toContain("mikuscore convert --from abc --to midi"); expect(topLevel.stdout).toContain("mikuscore convert --from midi --to musicxml"); + expect(topLevel.stdout).toContain("mikuscore convert --from mei --to musicxml"); + expect(topLevel.stdout).toContain("mikuscore convert --from lilypond --to musicxml"); expect(topLevel.stdout).toContain("mikuscore convert --from musescore --to musicxml"); expect(topLevel.stdout).toContain("mikuscore render svg"); expect(topLevel.stdout).toContain("mikuscore state summarize"); @@ -69,6 +72,54 @@ describe("mikuscore cli", () => { expect(result.stdout).toContain("STDIN"); }); + it("converts ABC directly to MIDI", () => { + const result = runCli(["convert", "--from", "abc", "--to", "midi"], { + input: "X:1\nT:ABC MIDI\nM:4/4\nL:1/4\nK:C\nC D E F|\n", + }); + + expect(result.status).toBe(0); + expect(result.stdout.length).toBeGreaterThan(0); + }); + + it("converts MEI directly to MusicXML", () => { + const result = runCli(["convert", "--from", "mei", "--to", "musicxml"], { + input: validMei("MEI import"), + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("MEI import"); + }); + + it("converts MusicXML directly to MEI", () => { + const result = runCli(["convert", "--from", "musicxml", "--to", "mei"], { + input: validMusicXml("MEI export"), + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("MEI export"); + }); + + it("converts LilyPond directly to MusicXML", () => { + const result = runCli(["convert", "--from", "lilypond", "--to", "musicxml"], { + input: validLilyPond(), + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(" { + const result = runCli(["convert", "--from", "musicxml", "--to", "lilypond"], { + input: validMusicXml("Lily export"), + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("\\version"); + expect(result.stdout).toContain("\\score"); + }); + it("writes output via --out", () => { const inputPath = writeTempFile("score.abc", "X:1\nT:Out\nM:4/4\nL:1/4\nK:C\nC D E F|\n"); const outPath = tempPath("out.musicxml"); @@ -414,7 +465,11 @@ describe("mikuscore cli", () => { const invalidAbc = runCli(["convert", "--from", "abc", "--to", "musicxml", "--in", inputPath]); const invalidMusicXmlPath = writeTempFile("invalid.musicxml", " { expect(invalidAbc.stderr).toContain("Failed to parse ABC"); expect(invalidMusicXml.status).toBe(1); expect(invalidMusicXml.stderr).toContain("Failed to parse MusicXML"); + expect(invalidMei.status).toBe(1); + expect(invalidMei.stderr).toContain("Failed to parse MEI"); + expect(invalidLilyPond.status).toBe(1); + expect(invalidLilyPond.stderr).toContain("Failed to parse LilyPond"); expect(unsupportedPair.status).toBe(2); expect(unsupportedPair.stderr).toContain("Unsupported conversion pair"); expect(missingFromTo.status).toBe(2); @@ -610,3 +669,48 @@ function validInsertableMusicXml(title: string) { `; } + +function validMei(title: string) { + return ` + + + + + ${title} + + + + + + + + + + + + +
+ + + + + + + + + + +
+
+
+ +
+
`; +} + +function validLilyPond() { + return `\\version "2.24.0" +\\score { + \\new Staff { c'4 d'4 e'4 f'4 } +}`; +}