From b30918af41cf9bf8e244ed980aa04ebf95c487a1 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 3 May 2026 20:38:47 +0200 Subject: [PATCH] feat(macros): add reader for global Macros file Importer for ProPresenter's protobuf-encoded `Macros` document. Exposes each macro's UUID and name plus the collections that group them. - src/MacroLibrary.php: top-level wrapper indexed by UUID and name - src/Macro.php, src/MacroCollection.php: per-entry wrappers - src/MacrosFileReader.php: file -> MacroLibrary entry point - bin/parse-macros.php: CLI listing macros and collections - tests/MacrosFileReaderTest.php: 10 tests against reference sample - doc/api/macros.md: API reference, plus INDEX/keywords updates --- AGENTS.md | 3 + bin/parse-macros.php | 66 +++++++++++++ doc/INDEX.md | 10 +- doc/api/macros.md | 151 ++++++++++++++++++++++++++++++ doc/keywords.md | 14 ++- doc/reference_samples/Macros | Bin 0 -> 9529 bytes src/Macro.php | 95 +++++++++++++++++++ src/MacroCollection.php | 64 +++++++++++++ src/MacroLibrary.php | 163 +++++++++++++++++++++++++++++++++ src/MacrosFileReader.php | 42 +++++++++ tests/MacrosFileReaderTest.php | 138 ++++++++++++++++++++++++++++ 11 files changed, 740 insertions(+), 6 deletions(-) create mode 100755 bin/parse-macros.php create mode 100644 doc/api/macros.md create mode 100644 doc/reference_samples/Macros create mode 100644 src/Macro.php create mode 100644 src/MacroCollection.php create mode 100644 src/MacroLibrary.php create mode 100644 src/MacrosFileReader.php create mode 100644 tests/MacrosFileReaderTest.php 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 0000000000000000000000000000000000000000..f49e40e764d72048efd2ec9749d7f637da9e2d7b GIT binary patch literal 9529 zcmd^FYlvOfb)GwRWcwyg>=RF`%ANKyOw(@3p?Q*0;Vj{l(jR&7RxeeAk}6y}iAqx7^;`HNSL+pqz2)jBft6 z*T1j-w&JO#JWG<3=R`_nNC=t}ZM|oPS;?7cnj`C5Tf@z@(QvZ4H8_37pE|Seo;&~g zwhyl?k#^M!Ec1CdEP;;5%YX77_wimNDS?_Ib2 zP45Jb^p5rzmU_W$Bs$h(~xiMTDo!c73_3g>l+WOOzQ+M|7 zxMQyV8T|3#L9h9CdnZmKOC@DOOVC8RtVnP)lB_kgNpb@hG;caK8m#3$~EpuVVth_R@fUpc;xBdl(X)+ft;a^b~qPT;NW$+_hN2M%0v(~p1u zr9%t%KL7m6;zyrfoW|`F-AzMOz=I${IEKK7 zgNO3b6NA%R8~A^B-_#HFz|=XzqDsUmjr(%NfPqMf7bS>5g-wa=ZffZ>?RjE|MfgMs z4|rc{%Snhr60JkZ!Dl16$5(&;Tk{|2pRZrNj>yyco=udO2<%%?EJ3hwXF!Hn7CCo@ zBMo%3$DbXqZa?1qcN>wyqP04ELRB#pQvx|n@*mmqeEvjGFruIB$XZ>Wli z;uwUxT#$<~z-O8fWsR0vn&gVx$(vhlX5K7y;ippI+#0pOW=Et~fV`ECSYI=cCfv*J z9cXrx3T`>=Z1r@y8YPsv!lt{@aMj=?YtpW8zyyT)JA&6tuJhEJo}orwniwKvLH+_9MQq6flEtt z?MYG>9rz_jtmX#$FN}wy$?muI#l@I$#(ncV!^j{RMtK6s%0&@}j``|qhIXF}S{5G5*sB56t{o<~7=qTaIF z-QDb#r{2)GGWrA~BGyiZ(nP$mi6o{n*W4z@?G@f=Jv^H?xDTlAfGK6NvIpi#C=7jE zo0t;!K6`r68@q(=s@||ciC_!ZNUiNsD6}JK(e0uH&SX|O+nFsl-{~?|ImVKuM01`| zGZQj`*Y?5T6VxtBrEM12L55yAZJPH@`z0`O;JLw}g?ql8Ru=bu>Ygd-_)rTuxH1ZF z2~q=)pvW<=xj+%5O6L*`_qln?s|zxz9XJ49Byqc zKa}71{zs3Ve#9R;@PQ}K?d|QGyC1aom+o&mUOo5x;k#Rc{ZG#wK6q1OtQHz<{na7DBv6b3{{~${!n@iGYKWMRT!@?)FaKXc^io$XyyTqP zfNH@&o;}m_2E2bh_7aX@1R#|XcY)%or6-2I+~2}=W4@w z7l<32@Rx?wE1)DOD-bD+*3~PRbsPiRG!#rTM4n7w_}s!D^mKi^m=xeFP-ct_$T{KB zLqR~4=U_8hg8$~GfARXX*ZfNxa;uojlqNzg0xea+T<-B51O!;b@EHTNksYpC*%dtZ znZNw=m4(0i$KO1(Sbuaqt{Yek#sLmifDAwZfJy)afe?dlnt<`lYe4Mvd3NW=>A-T6 zsC#$aWQxQH!3n08N>)7z2*reLOv2c13kxP*yl$n|uX$Y^nGg)h7Bm|~2`P#wK|He} z6v47HP)ROccNeDq6PeF`x1$_j$QD(XXev;jI7$<`C2|X0jXPynN};)TC%TWWkH=?6 z zIN3kZ7KDuH(ZH-u7*nN>=&c%URhYn|a>{b&W;5ITxaACiXIgZXjT9zwD43w*P&v_+ zLd49HytB5s?c~WprACPN_kaAqD+{0g#@ALBE1B!y{q@$8Wl3Q+0K6drVKPl9&m2K# zVA9?(ld3=WgcrYgep2PW-uHU{IlpwU-=-)kc+yNl0h1Ek5@;h(R52@yjPh4XPEZKm zbb4#`+%SzcpB%4#u$f!>t-h!(smv2;iS!130b9;M9a$X^QT8-)TSD%;AwteGQ6(TF7yBUxI``GO&@G#WTal&QC@cPL5Z%o*lem0kyI^W9dVE zRZTH~8aGTN^0pw_s^DSLVBRWfV)8l zrZJdo6hw{E>^*tpc>UbysUeh%7r#(xYO7f;y|ZsyE48avQqED=;q(YA5+^{h=Ozc# z7e|{nPd^T{wH}yje$+;cLoZR4Ac~BzON0!J4ud}inuO_ECJ(uzDW`J&^^8-zLhW~f zK}SL%eaPWa<3%LSeZE(Q<1-Xcd=$1_jPswHLHE9x_Bc z=tNO8Z#eU=eB9q)+&_A2{hkq(e6xU!0jfRL1(vwl$FmIr+7ia*!kb^XGVWCs|ptYglBK*1D#b{7JQoJAN8hzk z)W znzz0JgNr?C7XW;*wd%3B5h%GN$0`7dx(o7@l7i7O*860ZF9*QC{_F$biR%S`tN`H9 zJ0wNv#!!U(FAlmGh9)@A1}Z(6lhv0J)EkWc|FranYvUQmxQP8CDj#r$=mUBjqPh~w z3KX8^?zFzXu{n77$>9VIz#Wtdv7i5=-#@f)^s_IlEdKI;jHfEjms`?Pt+*uWK7?X6 zaA4ZOLJ$LGL0zFHU@d~yj~&!A{ikk5CA#(@39JDg2ipMM!MX;Q1oRmI4HKR58rjm(n@`WW{KtnItyNeqUi-_M&)<3C@*s%Pcz>_?i?#?09bFFK9odDd8$$bX*l7`!C9)s-2CNBOTk2j( z$xDqH2gRoLf-2Tvc>)?@A+K?eBFs+RD}$X^Oo_~5QEh!yCo6llb=8Cw4-{jnBxJaq zuiVys1#6WMAjZ~R4N9D-N8}JRF=J3z@mN~aiLE`@txF$i!Q??eWY!iFFRkSX4azHQ z4vSV9nk!h3(|89ldJi_^HyWeMm*Y#HYL8F^T2{uoDuYykT2@H~gp!mDVw(`8f?2!8 z^W*)s3X*GM)7OsPeP3y@b1f^_ef#UdX8F~H?#Wr!!rhaz?AfPZpJD&oJvqy&aB6dg z_1g5w8CFu=le4UAx+j7B*Ii|FPtLL*=$@QKV4vEYLFnE0_ia1ACIxB7`s-_y-IKFO zeBG0?h*DFVGl(wTle6d*-IHa8QNMd~mIUseoMm|Go}7hlcTdj3$o73~S{<3irdwkV tj9)!~kPKnffj&mBHFH>gco&*G{n^d+@%GlRmacro->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()); + } +}