diff --git a/AGENTS.md b/AGENTS.md index ec802b7..2e25e06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ All project documentation lives in `doc/`. Load only what you need. | Parse/modify `.pro` song files | `doc/api/song.md` | | Parse/modify `.proplaylist` files | `doc/api/playlist.md` | | Parse/modify `.probundle` files | `doc/api/bundle.md` | +| Read the global `Macros` file | `doc/api/macros.md` | | Understand `.pro` binary format | `doc/formats/pp_song_spec.md` | | Understand `.proplaylist` binary format | `doc/formats/pp_playlist_spec.md` | | Understand `.probundle` binary format | `doc/formats/pp_bundle_spec.md` | @@ -53,12 +54,14 @@ PHP tools for parsing, modifying, and generating ProPresenter 7 files: - **Songs** (`.pro`) — Protobuf-encoded presentation files with lyrics, groups, slides, arrangements, translations - **Playlists** (`.proplaylist`) — ZIP64 archives containing playlist metadata and embedded songs - **Bundles** (`.probundle`) — ZIP archives containing a single presentation with embedded media assets +- **Macros** (`Macros`, no extension) — Global protobuf-encoded macro library with collections ### CLI Tools ```bash php bin/parse-song.php path/to/song.pro php bin/parse-playlist.php path/to/playlist.proplaylist +php bin/parse-macros.php path/to/Macros ``` ### Key Source Files diff --git a/bin/parse-macros.php b/bin/parse-macros.php new file mode 100755 index 0000000..70bab8d --- /dev/null +++ b/bin/parse-macros.php @@ -0,0 +1,66 @@ +#!/usr/bin/env php +\n"; + exit(1); +} + +$filePath = $argv[1]; + +try { + $library = MacrosFileReader::read($filePath); +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} + +$macros = $library->getMacros(); +$collections = $library->getCollections(); + +echo "Macros (" . count($macros) . "):\n"; +foreach ($macros as $index => $macro) { + $number = $index + 1; + $name = $macro->getName(); + $uuid = $macro->getUuid(); + $actionCount = $macro->getActionCount(); + $startup = $macro->getTriggerOnStartup() ? ' (startup)' : ''; + + $memberships = $library->getCollectionsForMacro($macro); + $collectionNames = array_map(fn ($c) => $c->getName(), $memberships); + $collectionSuffix = $collectionNames === [] ? '' : ' [in: ' . implode(', ', $collectionNames) . ']'; + + $displayName = $name === '' ? '(unnamed)' : $name; + echo " [" . $number . "] " . $displayName . " :: " . $uuid . " (" . $actionCount . " action" . ($actionCount !== 1 ? "s" : "") . ")" . $startup . $collectionSuffix . "\n"; +} + +echo "\n"; + +if ($collections === []) { + echo "Collections: (none)\n"; + exit(0); +} + +echo "Collections (" . count($collections) . "):\n"; +foreach ($collections as $index => $collection) { + $number = $index + 1; + $name = $collection->getName(); + $uuid = $collection->getUuid(); + $resolvedMacros = $library->getMacrosForCollection($collection); + $count = count($resolvedMacros); + + $displayName = $name === '' ? '(unnamed)' : $name; + echo " [" . $number . "] " . $displayName . " :: " . $uuid . " (" . $count . " macro" . ($count !== 1 ? "s" : "") . ")\n"; + + foreach ($resolvedMacros as $macroIndex => $macro) { + $macroNumber = $macroIndex + 1; + $macroName = $macro->getName() === '' ? '(unnamed)' : $macro->getName(); + echo " " . $macroNumber . ". " . $macroName . " :: " . $macro->getUuid() . "\n"; + } +} diff --git a/doc/INDEX.md b/doc/INDEX.md index b677efc..db38615 100644 --- a/doc/INDEX.md +++ b/doc/INDEX.md @@ -9,6 +9,7 @@ | Parse/modify `.pro` song files | [api/song.md](api/song.md) | | Parse/modify `.proplaylist` files | [api/playlist.md](api/playlist.md) | | Parse/modify `.probundle` files | [api/bundle.md](api/bundle.md) | +| Read the global `Macros` file | [api/macros.md](api/macros.md) | | Understand `.pro` binary format | [formats/pp_song_spec.md](formats/pp_song_spec.md) | | Understand `.proplaylist` format | [formats/pp_playlist_spec.md](formats/pp_playlist_spec.md) | | Understand `.probundle` format | [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) | @@ -28,6 +29,7 @@ - [api/song.md](api/song.md) — Song parser API (read, modify, generate `.pro` files) - [api/playlist.md](api/playlist.md) — Playlist parser API (read, modify, generate `.proplaylist` files) - [api/bundle.md](api/bundle.md) — Bundle parser API (read, write `.probundle` files with media) +- [api/macros.md](api/macros.md) — Macros library API (read the global `Macros` file) ### Internal Reference - [internal/learnings.md](internal/learnings.md) — Development learnings and conventions discovered @@ -54,7 +56,8 @@ doc/ ├── api/ ← PHP API documentation │ ├── song.md │ ├── playlist.md -│ └── bundle.md +│ ├── bundle.md +│ └── macros.md └── internal/ ← Development notes (optional context) ├── learnings.md ├── decisions.md @@ -123,6 +126,8 @@ This project provides PHP tools to parse, modify, and generate ProPresenter 7 fi | `src/PresentationBundle.php` | Bundle wrapper (read/write `.probundle` files) | | `src/ProBundleReader.php` | Read `.probundle` files | | `src/ProBundleWriter.php` | Write `.probundle` files | +| `src/MacroLibrary.php` | Macros library wrapper (read global `Macros` file) | +| `src/MacrosFileReader.php` | Read global `Macros` file | ### CLI Tools @@ -132,4 +137,7 @@ php bin/parse-song.php path/to/song.pro # Parse and display playlist structure php bin/parse-playlist.php path/to/playlist.proplaylist + +# Parse and display the global Macros file +php bin/parse-macros.php path/to/Macros ``` diff --git a/doc/api/macros.md b/doc/api/macros.md new file mode 100644 index 0000000..6415d56 --- /dev/null +++ b/doc/api/macros.md @@ -0,0 +1,151 @@ +# Macros Library API + +> PHP module for reading the global ProPresenter `Macros` file (raw protobuf, +> no extension) and exposing each macro's name, UUID, and collection +> membership. + +## Quick Reference + +```php +use ProPresenter\Parser\MacrosFileReader; + +$library = MacrosFileReader::read('/path/to/Macros'); + +foreach ($library->getMacros() as $macro) { + $macro->getName(); // "Gottesdienst START" + $macro->getUuid(); // "FA0602E4-EDA2-4457-BB62-68AA17184217" + + foreach ($library->getCollectionsForMacro($macro) as $collection) { + $collection->getName(); // "Ablauf" + $collection->getUuid(); // "8D02FC57-83F8-4042-9B90-81C229728426" + } +} +``` + +--- + +## File Layout + +The `Macros` file is the protobuf-serialised +[`MacrosDocument`](../../proto/macros.proto): + +| Field | Type | Description | +|-------|------|-------------| +| `application_info` | message | ProPresenter version + flags that wrote the file | +| `macros` | repeated `Macro` | Definitions: UUID, name, color, actions, icon, startup flag | +| `macro_collections` | repeated `MacroCollection` | UUID, name, ordered list of `macro_id` references | + +Macros and collections live at the document root. Membership is by UUID +reference — a macro may appear in zero, one, or multiple collections. + +--- + +## Reading + +```php +use ProPresenter\Parser\MacrosFileReader; + +$library = MacrosFileReader::read('/Users/me/.../Macros'); +``` + +Throws `InvalidArgumentException` for missing files and `RuntimeException` for +empty / unreadable files. + +--- + +## MacroLibrary + +Top-level wrapper around `Rv\Data\MacrosDocument`. Indexes macros and +collections for fast lookup. + +```php +$library->getMacros(); // Macro[] +$library->getMacroByUuid('FA06...'); // ?Macro (case-insensitive) +$library->getMacroByName('Lied 1.Folie'); // ?Macro + +$library->getCollections(); // MacroCollection[] +$library->getCollectionByUuid('8D02...'); // ?MacroCollection (case-insensitive) +$library->getCollectionByName('Ablauf'); // ?MacroCollection + +// Cross-reference helpers +$library->getMacrosForCollection($collection); // Macro[] in declared order +$library->getCollectionsForMacro($macro); // MacroCollection[] (membership) + +$library->getDocument(); // \Rv\Data\MacrosDocument (raw protobuf) +``` + +--- + +## Macro + +```php +$macro->getUuid(); // "FA0602E4-..." +$macro->getName(); // "Gottesdienst START" +$macro->getColor(); // ['r'=>..,'g'=>..,'b'=>..,'a'=>..] | null +$macro->getTriggerOnStartup(); // bool +$macro->getActionCount(); // int — number of attached Action entries +$macro->getImageType(); // int — see Rv\Data\MacrosDocument\Macro\ImageType +$macro->getProto(); // \Rv\Data\MacrosDocument\Macro +``` + +Action payloads are not unwrapped by this library; reach for `getProto()` and +walk `getActions()` directly when needed. + +--- + +## MacroCollection + +```php +$collection->getUuid(); // "8D02FC57-..." +$collection->getName(); // "Ablauf" +$collection->getMacroUuids(); // string[] — referenced macro UUIDs in order +$collection->getProto(); // \Rv\Data\MacrosDocument\MacroCollection +``` + +Items use a protobuf `oneof ItemType`; only `macro_id` is currently defined. +Items without a populated reference are skipped. + +--- + +## CLI Tool + +```bash +php bin/parse-macros.php /path/to/Macros +``` + +Output: + +``` +Macros (24): + [1] Gottesdienst START :: FA0602E4-EDA2-4457-BB62-68AA17184217 (1 action) [in: Ablauf] + ... + +Collections (3): + [1] Ablauf :: 8D02FC57-83F8-4042-9B90-81C229728426 (12 macros) + 1. Gottesdienst START :: FA0602E4-EDA2-4457-BB62-68AA17184217 + ... +``` + +--- + +## Key Files + +| File | Purpose | +|------|---------| +| `src/MacroLibrary.php` | Document-level wrapper with lookup helpers | +| `src/Macro.php` | Single macro wrapper | +| `src/MacroCollection.php` | Collection wrapper | +| `src/MacrosFileReader.php` | Reads the `Macros` file | +| `bin/parse-macros.php` | CLI tool | +| `proto/macros.proto` | Protobuf schema | +| `generated/Rv/Data/MacrosDocument.php` | Generated message classes | + +--- + +## Scope Notes + +This module is read-only by design. Action editing, slide-side macro +references on `.pro` files (see `Slide::getMacroUuid()` / +`Slide::setMacro()`), and writing the `Macros` file back are not implemented +here. Add them by mirroring the `ProFileWriter` / `ProFileGenerator` pattern +when needed. diff --git a/doc/keywords.md b/doc/keywords.md index 73ee37d..f89cb01 100644 --- a/doc/keywords.md +++ b/doc/keywords.md @@ -66,7 +66,10 @@ | Keyword | Document | |---------|----------| -| macro | [api/song.md](api/song.md), [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5 | +| macro | [api/macros.md](api/macros.md), [api/song.md](api/song.md), [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5 | +| Macros file | [api/macros.md](api/macros.md) | +| MacroCollection | [api/macros.md](api/macros.md) | +| MacroLibrary | [api/macros.md](api/macros.md) | | media | [api/song.md](api/song.md), [api/bundle.md](api/bundle.md), [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5, [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) Section 3 | | image | [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5, [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) Section 3 | | video | [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5, [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) Section 6 | @@ -77,10 +80,11 @@ | Keyword | Document | |---------|----------| -| read | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md) | +| read | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md), [api/macros.md](api/macros.md) | | write | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md) | | generate | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md) | -| parse | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md) | +| parse | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md), [api/macros.md](api/macros.md) | +| MacrosFileReader | [api/macros.md](api/macros.md) | | ProFileReader | [api/song.md](api/song.md) | | ProFileWriter | [api/song.md](api/song.md) | | ProFileGenerator | [api/song.md](api/song.md) | @@ -92,8 +96,8 @@ | PresentationBundle | [api/bundle.md](api/bundle.md) | | Song | [api/song.md](api/song.md) | | PlaylistArchive | [api/playlist.md](api/playlist.md) | -| CLI | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md) | -| command line | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md) | +| CLI | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/macros.md](api/macros.md) | +| command line | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/macros.md](api/macros.md) | ## Protobuf diff --git a/doc/reference_samples/Macros b/doc/reference_samples/Macros new file mode 100644 index 0000000..f49e40e Binary files /dev/null and b/doc/reference_samples/Macros differ diff --git a/src/Macro.php b/src/Macro.php new file mode 100644 index 0000000..cfc5182 --- /dev/null +++ b/src/Macro.php @@ -0,0 +1,95 @@ +macro->getUuid()?->getString() ?? ''; + } + + /** + * Get the macro's display name. + */ + public function getName(): string + { + return $this->macro->getName(); + } + + /** + * Get the macro's color as an associative array, or null when unset. + * + * @return array{r: float, g: float, b: float, a: float}|null + */ + public function getColor(): ?array + { + if (!$this->macro->hasColor()) { + return null; + } + + $color = $this->macro->getColor(); + + return [ + 'r' => $color->getRed(), + 'g' => $color->getGreen(), + 'b' => $color->getBlue(), + 'a' => $color->getAlpha(), + ]; + } + + /** + * Whether the macro is configured to fire on application startup. + */ + public function getTriggerOnStartup(): bool + { + return $this->macro->getTriggerOnStartup(); + } + + /** + * Number of action entries attached to this macro. + * + * Action payloads are not exposed by this wrapper; use {@see getProto()} to + * inspect them via the generated protobuf classes. + */ + public function getActionCount(): int + { + return count($this->macro->getActions()); + } + + /** + * Icon enum value (see {@see \Rv\Data\MacrosDocument\Macro\ImageType}). + */ + public function getImageType(): int + { + return $this->macro->getImageType(); + } + + /** + * Get the underlying protobuf Macro message. + */ + public function getProto(): MacroProto + { + return $this->macro; + } +} diff --git a/src/MacroCollection.php b/src/MacroCollection.php new file mode 100644 index 0000000..6f8a42c --- /dev/null +++ b/src/MacroCollection.php @@ -0,0 +1,64 @@ +collection->getUuid()?->getString() ?? ''; + } + + /** + * Get the collection's display name (e.g. "Ablauf"). + */ + public function getName(): string + { + return $this->collection->getName(); + } + + /** + * Get the UUIDs of macros referenced by this collection, in order. + * + * Items in the protobuf use a `oneof` ItemType — currently only + * `macro_id` is defined. Items without a populated reference are skipped. + * + * @return string[] + */ + public function getMacroUuids(): array + { + $uuids = []; + foreach ($this->collection->getItems() as $item) { + $macroId = $item->getMacroId(); + if ($macroId !== null) { + $uuids[] = $macroId->getString(); + } + } + + return $uuids; + } + + /** + * Get the underlying protobuf MacroCollection message. + */ + public function getProto(): MacroCollectionProto + { + return $this->collection; + } +} diff --git a/src/MacroLibrary.php b/src/MacroLibrary.php new file mode 100644 index 0000000..e9372d6 --- /dev/null +++ b/src/MacroLibrary.php @@ -0,0 +1,163 @@ + */ + private array $macrosByUuid = []; + + /** @var array */ + private array $macrosByName = []; + + /** @var array */ + private array $collectionsByUuid = []; + + /** @var array */ + private array $collectionsByName = []; + + /** @var array */ + private array $collectionsByMacroUuid = []; + + public function __construct( + private readonly MacrosDocument $document, + ) { + foreach ($this->document->getMacros() as $macroProto) { + $macro = new Macro($macroProto); + $this->macros[] = $macro; + + $uuid = strtoupper($macro->getUuid()); + if ($uuid !== '') { + $this->macrosByUuid[$uuid] = $macro; + } + + $name = $macro->getName(); + if ($name !== '') { + $this->macrosByName[$name] = $macro; + } + } + + foreach ($this->document->getMacroCollections() as $collectionProto) { + $collection = new MacroCollection($collectionProto); + $this->collections[] = $collection; + + $uuid = strtoupper($collection->getUuid()); + if ($uuid !== '') { + $this->collectionsByUuid[$uuid] = $collection; + } + + $name = $collection->getName(); + if ($name !== '') { + $this->collectionsByName[$name] = $collection; + } + + foreach ($collection->getMacroUuids() as $macroUuid) { + $key = strtoupper($macroUuid); + if ($key === '') { + continue; + } + $this->collectionsByMacroUuid[$key][] = $collection; + } + } + } + + /** + * @return Macro[] + */ + public function getMacros(): array + { + return $this->macros; + } + + public function getMacroByUuid(string $uuid): ?Macro + { + return $this->macrosByUuid[strtoupper($uuid)] ?? null; + } + + public function getMacroByName(string $name): ?Macro + { + return $this->macrosByName[$name] ?? null; + } + + /** + * @return MacroCollection[] + */ + public function getCollections(): array + { + return $this->collections; + } + + public function getCollectionByUuid(string $uuid): ?MacroCollection + { + return $this->collectionsByUuid[strtoupper($uuid)] ?? null; + } + + public function getCollectionByName(string $name): ?MacroCollection + { + return $this->collectionsByName[$name] ?? null; + } + + /** + * Resolve a collection's referenced macros to {@see Macro} wrappers. + * + * Unknown UUIDs (referenced but not defined at document root) are skipped. + * + * @return Macro[] + */ + public function getMacrosForCollection(MacroCollection $collection): array + { + $resolved = []; + foreach ($collection->getMacroUuids() as $uuid) { + $macro = $this->getMacroByUuid($uuid); + if ($macro !== null) { + $resolved[] = $macro; + } + } + + return $resolved; + } + + /** + * Get the collections a macro belongs to (membership is by UUID + * reference). A macro may legally appear in zero, one, or many + * collections. + * + * @return MacroCollection[] + */ + public function getCollectionsForMacro(Macro $macro): array + { + $key = strtoupper($macro->getUuid()); + if ($key === '') { + return []; + } + + return $this->collectionsByMacroUuid[$key] ?? []; + } + + /** + * Get the underlying protobuf MacrosDocument. + */ + public function getDocument(): MacrosDocument + { + return $this->document; + } +} diff --git a/src/MacrosFileReader.php b/src/MacrosFileReader.php new file mode 100644 index 0000000..8de258e --- /dev/null +++ b/src/MacrosFileReader.php @@ -0,0 +1,42 @@ +mergeFromString($data); + + return new MacroLibrary($document); + } +} diff --git a/tests/MacrosFileReaderTest.php b/tests/MacrosFileReaderTest.php new file mode 100644 index 0000000..2190960 --- /dev/null +++ b/tests/MacrosFileReaderTest.php @@ -0,0 +1,138 @@ +expectException(InvalidArgumentException::class); + MacrosFileReader::read(__DIR__ . '/../doc/reference_samples/does-not-exist-macros'); + } + + #[Test] + public function readReturnsMacroLibraryWithExpectedCounts(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $this->assertInstanceOf(MacroLibrary::class, $library); + $this->assertCount(24, $library->getMacros()); + $this->assertCount(3, $library->getCollections()); + } + + #[Test] + public function macrosExposeNameAndUuid(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $first = $library->getMacros()[0]; + $this->assertInstanceOf(Macro::class, $first); + $this->assertSame('Gottesdienst START', $first->getName()); + $this->assertSame('FA0602E4-EDA2-4457-BB62-68AA17184217', $first->getUuid()); + } + + #[Test] + public function macroLookupByUuidIsCaseInsensitive(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $upper = $library->getMacroByUuid('FA0602E4-EDA2-4457-BB62-68AA17184217'); + $lower = $library->getMacroByUuid('fa0602e4-eda2-4457-bb62-68aa17184217'); + + $this->assertNotNull($upper); + $this->assertSame($upper, $lower); + $this->assertSame('Gottesdienst START', $upper->getName()); + } + + #[Test] + public function macroLookupByNameSucceeds(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $macro = $library->getMacroByName('Predigt - Text Lang'); + $this->assertNotNull($macro); + $this->assertSame('0A1543A9-7881-4537-982C-2933AA5472F8', $macro->getUuid()); + } + + #[Test] + public function collectionsExposeNameUuidAndOrderedMacroUuids(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $ablauf = $library->getCollectionByName('Ablauf'); + $this->assertInstanceOf(MacroCollection::class, $ablauf); + $this->assertSame('8D02FC57-83F8-4042-9B90-81C229728426', $ablauf->getUuid()); + + $uuids = $ablauf->getMacroUuids(); + $this->assertCount(12, $uuids); + $this->assertSame('FA0602E4-EDA2-4457-BB62-68AA17184217', $uuids[0]); + $this->assertSame('5ADFBB7A-1529-42B9-A9C6-77B7D01C4715', $uuids[11]); + } + + #[Test] + public function collectionLookupByUuidIsCaseInsensitive(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $upper = $library->getCollectionByUuid('AD18A4F6-135F-4A52-B92B-CA6619A55A9B'); + $lower = $library->getCollectionByUuid('ad18a4f6-135f-4a52-b92b-ca6619a55a9b'); + + $this->assertNotNull($upper); + $this->assertSame($upper, $lower); + $this->assertSame('AbsoluteTimer', $upper->getName()); + } + + #[Test] + public function getMacrosForCollectionResolvesReferences(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $absoluteTimer = $library->getCollectionByName('AbsoluteTimer'); + $this->assertNotNull($absoluteTimer); + + $macros = $library->getMacrosForCollection($absoluteTimer); + $this->assertCount(2, $macros); + $this->assertSame('Doors Open - 9:45', $macros[0]->getName()); + $this->assertSame('Godi START - 10:02', $macros[1]->getName()); + } + + #[Test] + public function getCollectionsForMacroReturnsMembership(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $macro = $library->getMacroByUuid('FA0602E4-EDA2-4457-BB62-68AA17184217'); + $this->assertNotNull($macro); + + $collections = $library->getCollectionsForMacro($macro); + $this->assertCount(1, $collections); + $this->assertSame('Ablauf', $collections[0]->getName()); + } + + #[Test] + public function startupMacroFlagSurfaces(): void + { + $library = MacrosFileReader::read(self::REFERENCE_PATH); + + $startup = array_values(array_filter( + $library->getMacros(), + fn (Macro $m) => $m->getTriggerOnStartup(), + )); + + $this->assertCount(1, $startup); + $this->assertSame('Doors Open - 9:45', $startup[0]->getName()); + } +}