<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.3">Jekyll</generator><link href="https://blog.karimratib.me/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.karimratib.me/" rel="alternate" type="text/html" /><updated>2026-05-21T02:51:31+00:00</updated><id>https://blog.karimratib.me/feed.xml</id><title type="html">infojunkie</title><subtitle></subtitle><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><entry><title type="html">Music Grimoire / music-i18n: 2025 progress report</title><link href="https://blog.karimratib.me/2025/10/07/music-grimoire-progress-2025.html" rel="alternate" type="text/html" title="Music Grimoire / music-i18n: 2025 progress report" /><published>2025-10-07T00:00:00+00:00</published><updated>2025-10-07T00:00:00+00:00</updated><id>https://blog.karimratib.me/2025/10/07/music-grimoire-progress-2025</id><content type="html" xml:base="https://blog.karimratib.me/2025/10/07/music-grimoire-progress-2025.html"><![CDATA[<p>In 2025, I had entirely too much fun working on the Music Grimoire / <code class="language-plaintext highlighter-rouge">music-i18n</code> project. After a few years of slow progress, my productivity shot through the roof this year! No doubt motivated by the interest that other members of the music coding community showed in the work, and the fruitful cooperations that ensued.</p>

<h2 id="contents">Contents</h2>
<ul>
  <li><a href="#a-review-of-last-years-goals">A review of last year’s goals</a></li>
  <li><a href="#a-concrete-project-to-focus-the-attention">A concrete project to focus the attention</a></li>
  <li><a href="#a-generic-pipeline-for-microtonal-music">A generic pipeline for microtonal music</a></li>
  <li><a href="#what-is-a-musical-tuning">What is a musical tuning?</a></li>
  <li><a href="#adding-microtonal-support-to-verovio">Adding microtonal support to Verovio</a></li>
  <li><a href="#the-multiple-accidentals-controversy">The multiple accidentals controversy</a></li>
  <li><a href="#more-tuning-toys">More tuning toys</a></li>
  <li><a href="#what-is-music-i18n-really">What is <code class="language-plaintext highlighter-rouge">music-i18n</code>, really?</a></li>
  <li><a href="#goals-for-the-coming-year">Goals for the coming year</a></li>
</ul>

<h2 id="a-review-of-last-years-goals">A review of last year’s goals</h2>
<p>Around the same time last year, I presented a <a href="/2024/10/01/music-grimoire-progress-report.html">progress report of where I was with my music work</a>. It ended with the following goals for this year:</p>

<ul>
  <li>
    <p><em>Embed playable music sheets into actual CMS systems, starting with my own Arabic Real Book sheets.</em> I did not work on this goal at all :cry: BUT! I did get involved in an exciting music publishing project that I will share below.</p>
  </li>
  <li>
    <p><em>Reach a milestone with <code class="language-plaintext highlighter-rouge">musicxml-mscx</code> to convert full music scores from MusicXML to MuseScore format.</em> I did reach a good milestone with this work. I focused on rendering style beyond what MusicXML affords, by accepting user-defined MuseScore stylesheets (<code class="language-plaintext highlighter-rouge">.mss</code>, which can be created from within MuseScore’s style settings) in the conversion process. For example, this module can now produce stylized MuseScore lead sheets like the following:</p>
  </li>
</ul>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/9-20-special-mscx.jpg">
      <img src="https://blog.karimratib.me/assets/9-20-special-mscx.jpg" style="max-width: 100%;" alt="A stylized MuseScore lead sheet produced by musicxml-mscx with a user-defined .mss stylesheet." />
    </a>
    <figcaption>A stylized MuseScore lead sheet produced by musicxml-mscx with a user-defined .mss stylesheet.</figcaption>
  </figure>
</div>

<ul>
  <li><em>Explore the feasibility of using pre-rendered scores in <code class="language-plaintext highlighter-rouge">musicxml-player</code> to replace resource-intensive JavaScript notation engines.</em> I dug deep to understand the various assets produced by MuseScore and Verovio cli tools. The outcome of this work was to create new <code class="language-plaintext highlighter-rouge">musicxml-player</code> classes <code class="language-plaintext highlighter-rouge">MuseScoreConverter</code>, <code class="language-plaintext highlighter-rouge">MuseScoreRenderer</code>, <code class="language-plaintext highlighter-rouge">VerovioStaticConverter</code>, <code class="language-plaintext highlighter-rouge">VerovioStaticRenderer</code> which accept asset files from these respective engravers, and require no additional JavaScript modules to render and interact with the scores. In brief, those engravers produce the following assets: SVG files for the rendered score, MIDI files for playback, and JSON metadata that describe how the various music objects (notes, measures, etc.) are laid out in time and space. The converter and renderer classes parse those various files to integrate them into the main player. Here’s an example of a simple score rendered to SVG / MIDI by Verovio and displayed by the <a href="https://blog.karimratib.me/demos/musicxml/?sheet=data/blackwood-ex-29.musicxml">MusicXML Player demo</a>:</li>
</ul>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/musicxml-player-static.jpg">
      <img src="https://blog.karimratib.me/assets/musicxml-player-static.jpg" style="max-width: 100%;" alt="The MusicXML Player can render static assets produced by various engravers." />
    </a>
    <figcaption>The MusicXML Player can render static assets produced by various engravers.</figcaption>
  </figure>
</div>

<ul>
  <li>
    <p><em>Replace my simplistic MIDI soft-synth in <code class="language-plaintext highlighter-rouge">musicxml-player</code> with a more complete one such as SpessaSynth.</em> I spent a few weeks integrating the awesome <a href="https://spessasus.github.io/SpessaSynth/">SpessaSynth</a>, and participating with its author to test their brand-new TypeScript version. This browser-based MIDI synth has an impressive list of features, and for me the deciding factor to adopt it was its support for <a href="https://en.wikipedia.org/wiki/MIDI_tuning_standard">MIDI Tuning Standard (MTS)</a>, which is an indispensable component of <code class="language-plaintext highlighter-rouge">music-i18n</code> (more on this below). Other than the synthesizer, SpessaSynth also features a mature sequencer that allowed me to significantly simplify my code and simultaneously gain exciting new features. The above screenshot of the latest version of the MusicXML Player shows how SpessaSynth is being used: As a local synth, as a sequencer, and as an external synth exposing a Web MIDI port. A major win on many fronts! :tada:</p>
  </li>
  <li>
    <p><em>Explore multiplayer playback in <code class="language-plaintext highlighter-rouge">musicxml-player</code>.</em> Honestly, I don’t know what got into me to mention this as a goal :roll_eyes: :shrug: I didn’t spend a single cycle thinking about this. Next!</p>
  </li>
  <li>
    <p><em>Support microtonality in MusicXML to MIDI conversion.</em> This goal, on the other hand, is where I spent the great majority of the year. It was all triggered by a fellow coder reaching out for my help with their <a href="https://www.maqamatna.com/">Arabic sheet music website Maqamatna</a>, to enhance the sheet page with an online music player (exactly like <code class="language-plaintext highlighter-rouge">musicxml-player</code> that I’ve been working on!) The big issue with Arabic music is that it includes notes (pitches) that are tuned differently than Western music, sort of like blues bends that are stuck halfway between a fret and the next on the guitar. This necessitates a rethink of the full music production pipeline, from music engraving to MIDI file production to online playback. Since this is the central use-case for my <code class="language-plaintext highlighter-rouge">music-i18n</code> vision, I gladly accepted this opportunity to jump into a concrete project that would challenge and solidify my vision. And how challenged I was! I will spend the rest of this post describing the details of the work that ensued.</p>
  </li>
  <li>
    <p><em>Expand the groove conversion algorithm in <code class="language-plaintext highlighter-rouge">musicxml-grooves</code> to handle full MIDI files.</em> Given the above, I could not devote much attention to this fascinating problem. My research led me to some very interesting academic experiments such as <a href="https://hal.science/hal-05230366v1">qparselib</a> (which has unfortunately gone closed-source recently). I hope to return to this problem in the future.</p>
  </li>
</ul>

<h2 id="a-concrete-project-to-focus-the-attention">A concrete project to focus the attention</h2>
<p>There’s nothing like a concrete project to crystallize a vision and challenge one’s fanciful assumptions against the cold, hard reality. When the Maqamatna author contacted me to apply my (claimed) expertise to their website, I knew I had found this opportunity and this challenge. I was a little nervous, because up to this point many details of my <code class="language-plaintext highlighter-rouge">music-i18n</code> vision had been left as exercises to the proverbial reader. Now, I needed to deliver on my claims. After a few sessions of elicitation and brainstorming, we put together a plan of action. In order to enhance the song page with playback, both the backend and the frontend need modifications:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/maqamatna.jpg">
      <img src="https://blog.karimratib.me/assets/maqamatna.jpg" style="max-width: 100%;" alt="The tasteful design of Maqamatna song page needs to be augmented with a sheet music player." />
    </a>
    <figcaption>The tasteful design of Maqamatna song page needs to be augmented with a sheet music player.</figcaption>
  </figure>
</div>

<p>We quickly agreed that the pipeline I had been working on applies well for this use-case:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/maqamatna-workflow.svg">
      <img src="https://blog.karimratib.me/assets/maqamatna-workflow-dark.svg" style="max-width: 100%;" alt="The general music workflow from score production to playback, including the selected technologies and formats." />
    </a>
    <figcaption>The general music workflow from score production to playback, including the selected technologies and formats.</figcaption>
  </figure>
</div>

<p>This high-level plan had many, many details to fill in. Most importantly, we had to select the technologies to generate the static assets on the backend and to render them visually and aurally on the frontend. The requirements are:</p>

<ul>
  <li>Generator can be invoked as a headless console tool</li>
  <li>Generator and renderer support Arabic music, including text, accidentals, tunings</li>
  <li>Renderer can play back the music with custom tunings - solved by integrating SpessaSynth as discussed earlier</li>
</ul>

<p>Out of the box, both MuseScore and Verovio support the first requirement, but not the second. In fact, microtonal / xenharmonic / world music support is severely lacking in most music software, especially in the open source domain. Even MusicXML, the W3C standard for music notation, does not address the question of microtonal music except in the most simplistic way of <a href="https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/alter/">specifying the pitch alteration for each note separately</a>. It has no concept of a “tuning”: How each degree in a scale is tuned, how it is represented in musical notation and how it maps to a MIDI key.</p>

<p>This was the main puzzle that I spent the following several months solving.</p>

<h2 id="a-generic-pipeline-for-microtonal-music">A generic pipeline for microtonal music</h2>
<p>I wanted to design a pipeline for microtonal music that would require the least modifications to existing softwares, and preferably none to existing standards. For each step in the pipeline above, we need to ensure that the important music features for this project are correctly preserved:</p>

<p>The pipeline starts with a MuseScore score that includes non-standard accidentals and possibly non-standard key signatures. MuseScore exports MusicXML files which are reasonably faithful to the original, including the correct accidentals and key signatures. In terms of accidentals, MusicXML supports <a href="https://www.smufl.org/">SMuFL symbols, which is another W3C standard that functions as an add-on to Unicode specialized in music symbols</a>. Although MuseScore also supports SMuFL accidentals, it exports them to MusicXML incorrectly and thus needs to be corrected (either by patching or by post-processing the output MusicXML document).</p>

<p>The next step is to convert the MusicXML to visual and audio assets that are passed to the frontend. I selected the excellent engraver <a href="https://www.verovio.org">Verovio</a> as the main conversion engine, instead of either MuseScore or my own <code class="language-plaintext highlighter-rouge">musicxml-midi</code> module. Verovio is attractive because it is a C++ console tool that specializes in converting scores to such assets, as opposed to MuseScore which is weighed down by a full GUI application. Verovio’s full functionality is also <a href="https://www.npmjs.com/package/verovio">cross-compiled to WASM</a>, making it doubly-attractive because any fixes to the backend would automatically reflect on the frontend. After evaluating Verovio’s almost-comprehensive MIDI output, I came to the conclusion that spending the effort understanding its codebase and patching it to fill the gaps would be much more efficient that recreating a full-featured and robust MIDI converter in my own <code class="language-plaintext highlighter-rouge">musicxml-midi</code> module. I decided to <a href="https://github.com/infojunkie/musicxml-midi/issues/54">rethink the conversion pipeline of <code class="language-plaintext highlighter-rouge">musicxml-midi</code></a> to delegate the MIDI conversion to Verovio, and focus instead on generating automatic accompaniment using MMA in this module. Since accompaniment generation is not a feature of the Maqamatna project, I left this part for later and kept going.</p>

<p>Now Verovio comes with its own set of challenges and idiosyncrasies. For one, its internal document representation is not based on MusicXML, but on <a href="https://music-encoding.org/">MEI (Music Encoding Initiative)</a>, another widely used music notation format. This means that whatever work I do in Verovio will also have to be compatible with MEI :sob: In my calculations, this was an unavoidable cost I would have to absorb. Fortunately, MEI is a mature format that is largely compatible with MusicXML so I was reasonably confident that the balance still tilted in the positive.</p>

<p>What is missing from Verovio to implement the microtonal features needed by Maqamatna?</p>
<ul>
  <li>Specifying a tuning that Verovio should apply to the MIDI export</li>
  <li>Converting that tuning to MTS (MIDI Tuning Standard) during MIDI export</li>
  <li>Carrying over non-standard accidentals in the score from one note to the next, including non-standard accidentals in the key signature</li>
  <li>Mapping notes/accidentals to their expected entries in the tuning, and thus exporting them correctly to MIDI</li>
</ul>

<h2 id="what-is-a-musical-tuning">What is a musical tuning?</h2>
<p>Whenever questions of musical tunings arise, the first place to check is the <a href="https://www.huygens-fokker.org/scala/index.html">incredible Scala application</a>, which is considered to be the reference implementation for all things microtonal. Scala <a href="https://www.huygens-fokker.org/scala/scl_format.html">has defined the de-facto standard to describe tunings</a>.</p>

<p>A tuning defines how each note should be tuned (i.e. its pitch) relative to some base note. Because the tuning is relative, we’re not using actual frequencies in Hertz but rather frequency ratios (for example, the octave ratio is 2/1 of the base note), or a logarithmic scale in units called “cents” where 1200 cents correspond to one octave, and 1200/12 = 100 cents correspond to one semitone. Here’s a Scala SCL file for the most common Arabic tuning, which in addition to the common 12 Western musical tones, includes 12 other tones tuned halfway between pairs of semitones (called quarter-tones):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>! 24-edo.scl
!
! This is a comment.
!
! Tuning description:
24 equal divisions of an octave. 24 proportionally equal and equal sounding semitone intervals per octave.
!
! Count of tones:
24
!
! List of tones:
50.0
100.0
150.0
200.0
250.0
300.0
350.0
400.0
450.0
500.0
550.0
600.0
650.0
700.0
750.0
800.0
850.0
900.0
950.0
1000.0
1050.0
1100.0
1150.0
2/1
</code></pre></div></div>

<p>This information alone is not enough to produce a MIDI file from a musical score. There are questions left unanswered:</p>
<ul>
  <li>What is the actual frequency of each tone in the different octaves?</li>
  <li>How will notes in the score map to the tones above?</li>
  <li>How will the tones map to MIDI notes in the output MIDI file?</li>
</ul>

<p>To answer these questions, we need to supply more information. After some research, I found that Ableton, the makers of the famous <a href="https://www.ableton.com/en/live/">Live DAW (Digital Audio Workstation)</a>, have created an <a href="https://help.ableton.com/hc/en-us/articles/10998372840220-ASCL-Specification">extension to the Scala SCL format</a> that answers these very questions:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>! 24-edo.ascl
!
! This is a comment.
!
! Tuning description:
24 equal divisions of an octave. 24 proportionally equal and equal sounding semitone intervals per octave.
!
! Count of tones:
24
!
! List of tones:
50.0
100.0
150.0
200.0
250.0
300.0
350.0
400.0
450.0
500.0
550.0
600.0
650.0
700.0
750.0
800.0
850.0
900.0
950.0
1000.0
1050.0
1100.0
1150.0
2/1
!
!!! ASCL EXTENSIONS START HERE !!!
!
! @ABL NOTE_NAMES "Bs/C" "C1qs" "Cs/Df" "Dbf" "D" "D1qs" "Ds/Ef" "Ebf" "E/Ff" "E1qs/Fbf" "Es/F"  ↩
"F1qs" "Fs/Gf" "Gbf" "G" "G1qs" "Gs/Af" "Abf" "A" "A1qs" "As/Bf" "Bbf" "B/Cf" "B1qs/Cbf"
! @ABL REFERENCE_PITCH 4 0 261.6256
</code></pre></div></div>

<p>Here, the ASCL file augments the SCL file with <code class="language-plaintext highlighter-rouge">@ABL</code> entries that specify the note names as they are expected to be found in the score, and a reference tone from which all other tones can be computed and which maps to the MIDI key 60 (midpoint-ish between 0-127). I gladly adopted the Ableton ASCL format to supply tuning information to Verovio, instead of inventing yet another format that would pollute the collective cognitive field :exploding_head:</p>

<h2 id="adding-microtonal-support-to-verovio">Adding microtonal support to Verovio</h2>
<p>Now I needed some C++ code to integrate ASCL parsing and processing inside Verovio. As I am loath to create unnecessary new code, I found a <a href="https://github.com/surge-synthesizer/tuning-library">header-only C++ library that parses Scala SCL files</a>, maintained by the same team that maintains the excellent <a href="https://surge-synthesizer.github.io/">open-source synth Surge XT</a>. Since ASCL provides relatively few extensions to SCL, I decided to take a chance and attempt a PR to add ASCL support to this library. In about a month, and thanks to the willingness and help of the library maintainers, I was able to <a href="https://github.com/surge-synthesizer/tuning-library/pull/77">get my changes integrated into <code class="language-plaintext highlighter-rouge">tuning-library</code></a> :tada:</p>

<p>With this PR done, I dove into the Verovio codebase. I’ve been working on <a href="https://github.com/infojunkie/verovio">a fork of the repo</a> for 3 months now, and I’ve reached a point where microtonal support is fully functional in both the console tool and the WASM module. The “theory of operation” of microtonal support goes as follows:</p>

<p>First, Verovio needs to accept Ableton ASCL tunings, whether in MusicXML files via the existing element <a href="https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/other-play/"><code class="language-plaintext highlighter-rouge">play/other-play[@type='tuning-ableton']</code></a>, or passed to the console tool via an option <code class="language-plaintext highlighter-rouge">verovio --tuning /path/to/tuning-file.ascl</code>, or in the JavaScript bindings via a new attribute <code class="language-plaintext highlighter-rouge">VerovioOptions.tuning</code> which takes an ASCL definition.</p>

<p>Here’s how the tuning looks when it’s embedded in a MusicXML file:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  [..]
  <span class="nt">&lt;part</span> <span class="na">id=</span><span class="s">"P1"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;measure</span> <span class="na">number=</span><span class="s">"1"</span> <span class="na">width=</span><span class="s">"224.66"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;sound&gt;</span>
        <span class="nt">&lt;play&gt;</span>
          <span class="nt">&lt;other-play</span> <span class="na">type=</span><span class="s">"tuning-ableton"</span><span class="nt">&gt;</span>
            <span class="cp">&lt;![CDATA[
! 24-edo.ascl
!
24 equal divisions of an octave. 24 proportionally equal and equal sounding semitone intervals per octave.
!
! default tuning: degree 18 (900.0 cents) 440 Hz, or degree 0 = 261.625565 Hz
!
24
!
50.
100.
150.
200.
250.
300.
350.
400.
450.
500.
550.
600.
650.
700.
750.
800.
850.
900.
950.
1000.
1050.
1100.
1150.
2/1
!
! Note names are formatted per MEI accidentals.
!
! @ABL NOTE_NAMES Bsharp/C Cquarter-sharp Csharp/Dflat Dslash-flat D Dquarter-sharp Dsharp/Eflat Eslash-flat  ↩
E/Fflat Equarter-sharp/Fslash-flat Esharp/F Fquarter-sharp Fsharp/Gflat Gslash-flat G Gquarter-sharp Gsharp/Aflat  ↩
Aslash-flat A Aquarter-sharp Asharp/Bflat Bslash-flat B/Cflat Bquarter-sharp/Cslash-flat
! @ABL REFERENCE_PITCH 4 0 261.6256
! @ABL NOTE_RANGE_BY_INDEX 0 21 6 4
! @ABL LINK https://www.ableton.com/learn-more/tuning-systems/24-edo
            ]]&gt;</span>
            <span class="nt">&lt;/other-play&gt;</span>
          <span class="nt">&lt;/play&gt;</span>
        <span class="nt">&lt;/sound&gt;</span>
      <span class="nt">&lt;attributes&gt;</span>
  [..]
</code></pre></div></div>
<p>and in JavaScript:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="nx">createVerovioModule</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">verovio/wasm</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">VerovioToolkit</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">verovio/esm</span><span class="dl">'</span><span class="p">;</span>

<span class="nf">createVerovioModule</span><span class="p">().</span><span class="nf">then</span><span class="p">(</span><span class="k">async</span> <span class="nx">VerovioModule</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">verovioToolkit</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">VerovioToolkit</span><span class="p">(</span><span class="nx">VerovioModule</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">score</span> <span class="o">=</span> <span class="k">await </span><span class="p">(</span><span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="dl">'</span><span class="s1">/path/to/score.musicxml</span><span class="dl">'</span><span class="p">)).</span><span class="nf">text</span><span class="p">();</span>
  <span class="nx">verovioToolkit</span><span class="p">.</span><span class="nf">loadData</span><span class="p">(</span><span class="nx">score</span><span class="p">);</span>
  <span class="nx">verovioToolkit</span><span class="p">.</span><span class="nf">setOptions</span><span class="p">({</span>
    <span class="na">font</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Bravura</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">tuning</span><span class="p">:</span> <span class="s2">`
! 24-edo.ascl
!
24 equal divisions of an octave. 24 proportionally equal and equal sounding semitone intervals per octave.
!
! default tuning: degree 18 (900.0 cents) 440 Hz, or degree 0 = 261.625565 Hz
!
24
!
50.
100.
150.
200.
250.
300.
350.
400.
450.
500.
550.
600.
650.
700.
750.
800.
850.
900.
950.
1000.
1050.
1100.
1150.
2/1
!
! Note names are formatted per MEI accidentals.
!
! @ABL NOTE_NAMES Bs/C C1qs Cs/Df Dbf D D1qs Ds/Ef Ebf E/Ff E1qs/Fbf Es/F F1qs Fs/Gf Gbf G G1qs Gs/Af  ↩
Abf A A1qs As/Bf Bbf B/Cf B1qs/Cbf
! @ABL REFERENCE_PITCH 4 0 261.6256
! @ABL NOTE_RANGE_BY_INDEX 0 21 6 4
! @ABL LINK https://www.ableton.com/learn-more/tuning-systems/24-edo
    `</span><span class="p">.</span><span class="nf">trim</span><span class="p">()</span>
  <span class="p">})</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The proverbial astute reader will have noticed that the <code class="language-plaintext highlighter-rouge">@ABL NOTE_NAMES</code> differ in the MusicXML case from the Verovio case. In the former, the accidentals are formulated as <a href="https://www.w3.org/2021/06/musicxml40/musicxml-reference/data-types/accidental-value/">MusicXML accidentals</a>, whereas in the latter, they are formulated as <a href="https://music-encoding.org/guidelines/v5/data-types/data.ACCIDENTAL.WRITTEN.html">MEI accidentals</a>. It’s worth mentioning that neither MusicXML nor MEI encompass the full set of <a href="https://danielku15.github.io/smufl-viewer/?search=accidental">SMuFL accidentals</a> (488 at the latest count), so in both cases SMuFL accidentals are also recognized as valid names.</p>

<p>Once stored in Verovio’s internal data store, the tuning is ready to be used during MIDI export. This happens in two steps:</p>

<ol>
  <li>
    <p>Export an MTS (MIDI Tuning Standard) message based on the tuning information above. This is a <a href="https://midi.org/midi-tuning-updated-specification">MIDI SysEx message</a> to which we pass the retuned frequencies of all 128 MIDI keys. The new frequencies are computed by <code class="language-plaintext highlighter-rouge">tuning-library</code>.</p>
  </li>
  <li>
    <p>With the MIDI tuning now in place, we need to find the MIDI key that corresponds to each note in the score. For this, we maintain a map between the incoming <code class="language-plaintext highlighter-rouge">@ABL NOTE_NAMES</code> and the tuning entries. Again, <code class="language-plaintext highlighter-rouge">tuning-library</code> knows how to map tuning entries to MIDI keys, so we output this result to the MIDI file, instead of the default MIDI key that would have been sent in the absence of a tuning.</p>
  </li>
</ol>

<p>To clarify what this means, let’s look at the <code class="language-plaintext highlighter-rouge">24-edo.ascl</code> tuning file just above in the context of an actual score. For lovers of Arabic music, you can follow <a href="https://www.youtube.com/watch?v=xN7E1pc8Y2Y&amp;list=PLcfDkfaWrWRTX-UreYE-cY5rq9jkstHL3">Sami Abu Shumays’ excellent maqam tutorials</a>, of which this is an excerpt:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/shumays.png">
      <img src="https://blog.karimratib.me/assets/shumays.png" style="max-width: 100%;" alt="A transcription of the intro to Sami Abu Shumays' first maqam lesson." />
    </a>
    <figcaption>A transcription of the intro to Sami Abu Shumays' first maqam lesson.</figcaption>
  </figure>
</div>

<p>Examining this score, we find two half-flat notes: <strong>B½♭</strong> and <strong>E½♭</strong>, notated in their <a href="https://danielku15.github.io/smufl-viewer/?search=accidentalQuarterToneFlatArabic">typical Arabic / Turkish notation</a>. Expressed in <a href="https://music-encoding.org/guidelines/v5/data-types/data.ACCIDENTAL.aeu.html">MEI syntax</a>, this gives us <strong>Bbf</strong> and <strong>Ebf</strong>, which we can find in the <code class="language-plaintext highlighter-rouge">@ABL NOTE_NAMES</code> above, corresponding to 350¢ and 1050¢ respectively.</p>

<p>We now map the 24 tuning tones (per octave) to MIDI, starting at the <code class="language-plaintext highlighter-rouge">@ABL REFERENCE_PITCH</code> (C4) which we map to MIDI key 60. Here’s the output of <code class="language-plaintext highlighter-rouge">tuning-library</code>’s utility <code class="language-plaintext highlighter-rouge">showmapping</code> with the tuning above, spanning all 128 MIDI keys:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ showmapping 24-edo.ascl
Note,        Freq (Hz),        ScaledFrq,        logScaled,  Pos, Name
   0,    46.2493028390,     5.6568542495,     2.5000000000,   12, Fs/Gf
   1,    47.6045108553,     5.8226127314,     2.5416666667,   13, Gbf
[..]
  50,   195.9977179909,    23.9729132300,     4.5833333333,   14, G
  51,   201.7408895050,    24.6753732065,     4.6250000000,   15, G1qs
  52,   207.6523487900,    25.3984168315,     4.6666666667,   16, Gs/Af
  53,   213.7370270538,    26.1426472519,     4.7083333333,   17, Abf
  54,   220.0000000000,    26.9086852881,     4.7500000000,   18, A
  55,   226.4464920616,    27.6971699522,     4.7916666667,   19, A1qs
  56,   233.0818807590,    28.5087589805,     4.8333333333,   20, As/Bf
  57,   239.9117011864,    29.3441293825,     4.8750000000,   21, Bbf
  58,   246.9416506281,    30.2039780058,     4.9166666667,   22, B/Cf
  59,   254.1775933119,    31.0890221169,     4.9583333333,   23, B1qs/Cbf
  60,   261.6255653006,    32.0000000000,     5.0000000000,    0, Bs/C
  61,   269.2917795270,    32.9376715726,     5.0416666667,    1, C1qs
  62,   277.1826309769,    33.9028190195,     5.0833333333,    2, Cs/Df
  63,   285.3047020232,    34.8962474453,     5.1250000000,    3, Dbf
  64,   293.6647679174,    35.9187855459,     5.1666666667,    4, D
  65,   302.2698024408,    36.9712862999,     5.2083333333,    5, D1qs
  66,   311.1269837221,    38.0546276801,     5.2500000000,    6, Ds/Ef
  67,   320.2437002253,    39.1697133857,     5.2916666667,    7, Ebf
  68,   329.6275569129,    40.3174735966,     5.3333333333,    8, E/Ff
  69,   339.2863815897,    41.4988657488,     5.3750000000,    9, E1qs/Fbf
  70,   349.2282314330,    42.7148753334,     5.4166666667,   10, Es/F
  71,   359.4613997130,    43.9665167187,     5.4583333333,   11, F1qs
  72,   369.9944227116,    45.2548339959,     5.5000000000,   12, Fs/Gf
  73,   380.8360868427,    46.5809018509,     5.5416666667,   13, Gbf
  74,   391.9954359817,    47.9458264601,     5.5833333333,   14, G
  75,   403.4817790101,    49.3507464131,     5.6250000000,   15, G1qs
  76,   415.3046975799,    50.7968336630,     5.6666666667,   16, Gs/Af
  77,   427.4740541076,    52.2852945037,     5.7083333333,   17, Abf
  78,   440.0000000000,    53.8173705762,     5.7500000000,   18, A
  79,   452.8929841231,    55.3943399044,     5.7916666667,   19, A1qs
  80,   466.1637615181,    57.0175179610,     5.8333333333,   20, As/Bf
  81,   479.8234023727,    58.6882587651,     5.8750000000,   21, Bbf
  82,   493.8833012561,    60.4079560116,     5.9166666667,   22, B/Cf
  83,   508.3551866238,    62.1780442338,     5.9583333333,   23, B1qs/Cbf
  84,   523.2511306012,    64.0000000000,     6.0000000000,    0, Bs/C
  85,   538.5835590540,    65.8753431452,     6.0416666667,    1, C1qs
  86,   554.3652619537,    67.8056380390,     6.0833333333,    2, Cs/Df
  87,   570.6094040464,    69.7924948906,     6.1250000000,    3, Dbf
  88,   587.3295358348,    71.8375710918,     6.1666666667,    4, D
  89,   604.5396048816,    73.9425725998,     6.2083333333,    5, D1qs
  90,   622.2539674442,    76.1092553602,     6.2500000000,    6, Ds/Ef
  91,   640.4874004506,    78.3394267715,     6.2916666667,    7, Ebf
  92,   659.2551138257,    80.6349471933,     6.3333333333,    8, E/Ff
  93,   678.5727631795,    82.9977314977,     6.3750000000,    9, E1qs/Fbf
  94,   698.4564628660,    85.4297506669,     6.4166666667,   10, Es/F
  95,   718.9227994261,    87.9330334373,     6.4583333333,   11, F1qs
  96,   739.9888454233,    90.5096679919,     6.5000000000,   12, Fs/Gf
  97,   761.6721736854,    93.1618037019,     6.5416666667,   13, Gbf
  98,   783.9908719635,    95.8916529201,     6.5833333333,   14, G
  99,   806.9635580201,    98.7014928261,     6.6250000000,   15, G1qs
[..]
 127,  1811.5719364925,   221.5773596176,     7.7916666667,   19, A1qs
</code></pre></div></div>

<p>From this table, Verovio is now able to output the correct notes to the MIDI file! <a href="https://blog.karimratib.me/demos/musicxml/?sheet=data/shumays.musicxml">Here’s what this particular transcription sounds like</a>.</p>

<h2 id="the-multiple-accidentals-controversy">The multiple accidentals controversy</h2>
<p>Are multiple accidentals per note accepted in music theory?</p>

<p>The answer seems to vary depending on who you ask. But for <code class="language-plaintext highlighter-rouge">music-i18n</code>, the answer is a resounding YES! Witness this excerpt from the Extended Helmholtz-Ellis JI Pitch Notation booklet:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/notation-heji.png">
      <img src="https://blog.karimratib.me/assets/notation-heji.png" style="max-width: 100%;" alt="The Extended Helmholtz-Ellis JI Pitch Notation supports multiple accidentals that fine-tune the pitch of playable notes." />
    </a>
    <figcaption>The Extended Helmholtz-Ellis JI Pitch Notation supports multiple accidentals that fine-tune the pitch of playable notes.</figcaption>
  </figure>
</div>

<p>Or this microtonal tune written in the encyclopedic Sagittal Notation:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/notation-sagittal.png">
      <img src="https://blog.karimratib.me/assets/notation-sagittal.png" style="max-width: 100%;" alt="Sagittal Notation includes two modes of expressing accidentals: Mixed (top) and pure (bottom). Mixed notation combines new symbols with traditional sharps and flats." />
    </a>
    <figcaption>Sagittal Notation includes two modes of expressing accidentals: Mixed (top) and pure (bottom). Mixed notation combines new symbols with traditional sharps and flats.</figcaption>
  </figure>
</div>

<p>Software support for multiple accidentals is also uneven:</p>

<ul>
  <li>
    <p>MusicXML flatly does not support multiple accidentals per note, because <a href="https://github.com/w3c/musicxml/blob/gh-pages/schema/musicxml.xsd#L5226">the <code class="language-plaintext highlighter-rouge">note/accidental</code> element is only accepted once</a>. I sadly resolved to <a href="https://github.com/infojunkie/musicxml/commit/eb9648564a729b1acdc3e91daeecc09ce72e8d89">fork and patch the MusicXML schema to support multiple accidentals</a>, until I prepare a good case to support this feature in the official version :sweat_smile:</p>
  </li>
  <li>
    <p>Whereas <a href="https://github.com/rism-digital/verovio/issues/4185">MEI does technically support the notion of multiple accidentals per note, it seems the Verovio maintainers are unsure about it</a>. Which means it’s up to me to ensure this support fully works for MusicXML import, MIDI export and even regular visual engraving :sweat_smile: :sweat_smile:</p>
  </li>
</ul>

<h2 id="more-tuning-toys">More tuning toys</h2>
<p>In order to test the patched Verovio, I created some utilities to create tuning files. In the Unix spirit, those are small separate tools that are combined to build up the final result. It’s best to be familiar with the Linux environment to run these comfortably:</p>

<ul>
  <li>A <a href="https://github.com/infojunkie/scalextric/blob/main/src/build/accidentals/sagittals.py">Python script</a> to convert the <a href="https://sagittal.org/Sagittal-SMuFL-Map.ods">Sagittal reference spreadsheet</a> to JSON:</li>
</ul>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"accidentalNatural"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"range"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Conventional Sagittal-compatible accidentals"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"unicode"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"character"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
            </span><span class="nl">"code_point"</span><span class="p">:</span><span class="w"> </span><span class="s2">"U+E261"</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"sagitype"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"long"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"revo_pure"</span><span class="p">:</span><span class="w"> </span><span class="s2">"|//|"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"evo_mixed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"comma"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"sharp_flat"</span><span class="p">:</span><span class="w"> </span><span class="s2">"h or |//|"</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"evo_mixed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"comma"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"sharp_flat"</span><span class="p">:</span><span class="w"> </span><span class="s2">"h"</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"pitch"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"sharp_flat"</span><span class="p">:</span><span class="w"> </span><span class="s2">"natural"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"commatic_alteration"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"direction"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"cents"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.0</span><span class="p">,</span><span class="w">
            </span><span class="nl">"ratio"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"numerator"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
                </span><span class="nl">"denominator"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"prime_count_vector"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"2"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"3"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"5"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"7"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"11"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"13"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"17"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"19"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"23"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"29"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"31"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"37"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"ji_pitches"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"3^-2"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"3^-1"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"3^0"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"3^1"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"3^2"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"notation_membership"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"prime_factor"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"12_relative_fractions"</span><span class="p">:</span><span class="w"> </span><span class="s2">"natural"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"12_relative_cents"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.0</span><span class="p">,</span><span class="w">
            </span><span class="nl">"edo_degrees"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"17"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"19"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"22"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"27"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"29"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"31"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"34"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"39"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"41"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"43"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"46"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"50"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"53"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"60"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"72"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"96"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"sagispeak"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"simple"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"spelling"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_1"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_2"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_3"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_4"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"alternative"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"spelling"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_1"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_2"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_3"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_4"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"sharp_flat"</span><span class="p">:</span><span class="w"> </span><span class="s2">"natural"</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"symbol"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"symbol_or_accent"</span><span class="p">:</span><span class="w"> </span><span class="s2">"symbol"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"shaft_count"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"smufl"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"glyph_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"accidentalNatural"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Natural"</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"glyph_description"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"graphical"</span><span class="p">:</span><span class="w"> </span><span class="s2">"natural"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"heraldic"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Hera's throne"</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
</span><span class="p">[</span><span class="err">..</span><span class="p">]</span><span class="w">
    </span><span class="nl">"accSagittal11LargeDiesisUp"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"range"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Spartan Sagittal single-shaft accidentals (U+E300–U+E30F)"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"unicode"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"character"</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
            </span><span class="nl">"code_point"</span><span class="p">:</span><span class="w"> </span><span class="s2">"U+E30C"</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"sagitype"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"long"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"revo_pure"</span><span class="p">:</span><span class="w"> </span><span class="s2">"(|)"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"evo_mixed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"comma"</span><span class="p">:</span><span class="w"> </span><span class="s2">"(|)"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"sharp_flat"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"evo_mixed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"comma"</span><span class="p">:</span><span class="w"> </span><span class="s2">"m"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"sharp_flat"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"pitch"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"sharp_flat"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"commatic_alteration"</span><span class="p">:</span><span class="w"> </span><span class="s2">"11-L-diesis"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"direction"</span><span class="p">:</span><span class="w"> </span><span class="s2">"up"</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"cents"</span><span class="p">:</span><span class="w"> </span><span class="mf">60.412</span><span class="p">,</span><span class="w">
            </span><span class="nl">"ratio"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"numerator"</span><span class="p">:</span><span class="w"> </span><span class="mi">729</span><span class="p">,</span><span class="w">
                </span><span class="nl">"denominator"</span><span class="p">:</span><span class="w"> </span><span class="mi">704</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"prime_count_vector"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"2"</span><span class="p">:</span><span class="w"> </span><span class="mi">-6</span><span class="p">,</span><span class="w">
                </span><span class="nl">"3"</span><span class="p">:</span><span class="w"> </span><span class="mi">6</span><span class="p">,</span><span class="w">
                </span><span class="nl">"5"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"7"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
                </span><span class="nl">"11"</span><span class="p">:</span><span class="w"> </span><span class="mi">-1</span><span class="p">,</span><span class="w">
                </span><span class="nl">"13"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"17"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"19"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"23"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"29"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"31"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"37"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"ji_pitches"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"3^-2"</span><span class="p">:</span><span class="w"> </span><span class="s2">"128/99"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"3^-1"</span><span class="p">:</span><span class="w"> </span><span class="s2">"64/33"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"3^0"</span><span class="p">:</span><span class="w"> </span><span class="s2">"16/11"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"3^1"</span><span class="p">:</span><span class="w"> </span><span class="s2">"12/11"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"3^2"</span><span class="p">:</span><span class="w"> </span><span class="s2">"18/11"</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"notation_membership"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"prime_factor"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"12_relative_fractions"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"12_relative_cents"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
            </span><span class="nl">"edo_degrees"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"17"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"19"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"22"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"27"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"29"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"31"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"34"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"39"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
                </span><span class="nl">"41"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"43"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"46"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
                </span><span class="nl">"50"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"53"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"60"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"72"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"96"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"sagispeak"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"simple"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"spelling"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jatai"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_1"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/dʒɐ ˈtaɪ/"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_2"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/ʒɐ ˈtaɪ/"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_3"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/jɐ ˈtaɪ/"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_4"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/hɐ ˈtaɪ/"</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"alternative"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"spelling"</span><span class="p">:</span><span class="w"> </span><span class="s2">"wai"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_1"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/waɪ/"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_2"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_3"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span><span class="w">
                </span><span class="nl">"ipa_4"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"sharp_flat"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"symbol"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"symbol_or_accent"</span><span class="p">:</span><span class="w"> </span><span class="s2">"symbol"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"shaft_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"smufl"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"glyph_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"accSagittal11LargeDiesisUp"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"11 large diesis up, (11L), (sharp less 11M), 3° up [46-EDO]"</span><span class="w">
        </span><span class="p">},</span><span class="w">
        </span><span class="nl">"glyph_description"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"graphical"</span><span class="p">:</span><span class="w"> </span><span class="s2">"double arc up"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"heraldic"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Dionysus' wine cup"</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
</span><span class="p">[</span><span class="err">..</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<ul>
  <li>A <a href="https://github.com/infojunkie/scalextric/blob/main/src/build/accidentals/merge_smufl_sagittals.jq">jq script</a> to convert the Sagittal JSON above + <a href="https://github.com/w3c/smufl/blob/gh-pages/metadata/ranges.json">SMuFL accidentals</a> into a JSON map of accidental =&gt; pitch alteration. The resulting file needs to be edited manually for the missing accidentals - <a href="https://github.com/infojunkie/musicxml-midi/blob/main/src/smufl.json">see my current version</a>. This script was mostly vibe-coded and it took multiple iterations before :robot: got it right!!</li>
</ul>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"accidentalDoubleFlatArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">-200</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalThreeQuarterTonesFlatArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">-150</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalFlatArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">-100</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalQuarterToneFlatArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">-50</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalNaturalArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalQuarterToneSharpArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalSharpArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalThreeQuarterTonesSharpArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">150</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalDoubleSharpArabic"</span><span class="p">:</span><span class="w"> </span><span class="mi">200</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalBuyukMucennebFlat"</span><span class="p">:</span><span class="w"> </span><span class="mf">-181.1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalKucukMucennebFlat"</span><span class="p">:</span><span class="w"> </span><span class="mf">-113.2</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalBakiyeFlat"</span><span class="p">:</span><span class="w"> </span><span class="mf">-90.6</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalKomaFlat"</span><span class="p">:</span><span class="w"> </span><span class="mf">-22.6</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalKomaSharp"</span><span class="p">:</span><span class="w"> </span><span class="mf">22.6</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalBakiyeSharp"</span><span class="p">:</span><span class="w"> </span><span class="mf">90.6</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalKucukMucennebSharp"</span><span class="p">:</span><span class="w"> </span><span class="mf">113.2</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalBuyukMucennebSharp"</span><span class="p">:</span><span class="w"> </span><span class="mf">181.1</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal7v11KleismaUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">9.688</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal7v11KleismaDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-9.688</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal17CommaUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">14.73</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal17CommaDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-14.73</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal55CommaUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">31.767</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal55CommaDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-31.767</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal7v11CommaUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">33.148</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal7v11CommaDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-33.148</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal5v11SmallDiesisUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">38.906</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittal5v11SmallDiesisDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-38.906</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp5v11SDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">74.779</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat5v11SUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-74.779</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp7v11CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">80.537</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat7v11CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-80.537</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp55CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">81.918</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat55CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-81.918</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp17CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">98.955</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat17CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-98.955</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp7v11kDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">103.997</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat7v11kUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-103.997</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp7v11kUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">123.373</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat7v11kDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-123.373</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp17CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">128.415</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat17CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-128.415</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp55CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">145.452</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat55CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-145.452</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp7v11CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">146.833</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat7v11CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-146.833</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalSharp5v11SUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">152.591</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalFlat5v11SDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-152.591</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleSharp5v11SDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">188.464</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleFlat5v11SUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-188.464</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleSharp7v11CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">194.222</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleFlat7v11CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-194.222</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleSharp55CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">195.603</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleFlat55CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-195.603</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleSharp17CDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">212.64</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleFlat17CUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-212.64</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleSharp7v11kDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">217.682</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accSagittalDoubleFlat7v11kUp"</span><span class="p">:</span><span class="w"> </span><span class="mf">-217.682</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accidentalDoubleFlatOneArrowDown"</span><span class="p">:</span><span class="w"> </span><span class="mf">-248.88</span><span class="p">,</span><span class="w">
</span><span class="p">[</span><span class="err">..</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<ul>
  <li>Finally, <a href="https://github.com/infojunkie/musicxml-midi/blob/main/src/xsl/tuning.xsl">an XSL script to parse a MusicXML score into an ASCL tuning</a>. The script gathers note+accidental combinations in the MusicXML score, and emits a correctly-formatted tuning file for those combinations. The SMuFL accidentals JSON file above is consulted to obtain the right pitch alteration in case it’s missing in the score. The <a href="https://github.com/infojunkie/musicxml-midi/blob/main/src/xsl/lib-musicxml.xsl">reusable XSL library <code class="language-plaintext highlighter-rouge">lib-musicxml.xsl</code></a> does the heavy lifting of computing carried-over and implicit accidentals throughout the score :raised_hands:</li>
</ul>

<h2 id="what-is-music-i18n-really">What is <code class="language-plaintext highlighter-rouge">music-i18n</code>, really?</h2>
<p>I keep mentioning <code class="language-plaintext highlighter-rouge">music-i18n</code> in this post, without defining it. What do I mean by this term?</p>

<p>Just like human languages, I consider music to be a language with many dialects. Pardon the imprecise analogy: My aim is not to dive into linguistics or ethnomusicology. At the moment, and for the most part, existing music software are speaking one specific dialect of music: The Western mainstream dialect, based on 12 tones tuned to specific frequencies and from which a huge edifice of theory and assumptions has been built. Music software followed suit and integrated the theory and assumptions of mainstream Western music down to its deepest layers. It takes significant re-design and re-engineering effort to go back and refactor the many software layers to accommodate other assumptions, or to provide generalizations that fit multiple music dialects, without disrupting the whole software edifice.</p>

<p>Thankfully, music standards today support global music systems to a large extent. To cite some examples that I mentioned above:</p>

<ul>
  <li>The MIDI Tuning Standard (MTS) allows to reprogram the 128 MIDI keys to any tuning, outside the mainstream 12 tones</li>
  <li>SMuFL is a wonderful W3C standard that extends Unicode with an ever-increasing set of musical symbols from different music cultures, and includes a reference font</li>
  <li>MusicXML supports a wide range of accidentals beyond the mainstream ones, including all SMuFL symbols, both for individual notes and for key signatures</li>
</ul>

<p>I view <code class="language-plaintext highlighter-rouge">music-i18n</code> as an umbrella term for any activity that goes towards retrofitting existing music software (and creating more) with the ability to handle non-mainstream music systems, reusing to the fullest the capabilities of open standards, and working on extending those standards when they fall short. Of course, musicians and programmers all over the world routinely engage in <code class="language-plaintext highlighter-rouge">music-i18n</code> as they attempt to express non-Western musical cultures in software. I find that thinking about this activity holistically helps organize the individual efforts and comprehend its full scope.</p>

<h2 id="goals-for-the-coming-year">Goals for the coming year</h2>
<p>Wow, this was a mouthful! Thank you for reading all the way down. Where am I going from here?</p>

<p>First and foremost, I’ll be focusing on completing the Maqamatna work to see it through release. This will validate the work done over the year and provide a valuable new resource to the Arabic music community.</p>

<p>I also want to submit my work on Verovio for inclusion in the mainline. I expect that <a href="https://github.com/infojunkie/verovio/pull/2">my current fork</a> will need to be divided into smaller patches for submission, and with fairly significant changes before they are accepted. I hope the core maintainers will support my work in this PR :crossed_fingers:</p>

<p>Similarly for <a href="https://github.com/infojunkie/musicxml/pull/1">my MusicXML modifications</a> to support multiple accidentals and other <code class="language-plaintext highlighter-rouge">music-i18n</code> elements, I will work on submitting them for inclusion :pray:</p>

<p>When the microtonal dust settles, I hope I can devote some attention to world rhythms. This has always been a personal interest in my music practice, and I hope to express it in software soon :drum:</p>

<p>As always, I hope to hear from interested readers and I welcome all opportunities for collaboration! :saxophone: :handshake:</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="music" /><summary type="html"><![CDATA[In this post, I present a summary of the work done last year on my music project. The focus has been on realizing the vision of music-i18n by creating a full microtonal pipeline from MusicXML to MIDI and Web playback.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/musicxml-player-static.jpg" /><media:content medium="image" url="https://blog.karimratib.me/assets/musicxml-player-static.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: Getting Config Ignore and Webform to play nice together</title><link href="https://blog.karimratib.me/2025/06/05/drupal-config-ignore-webform.html" rel="alternate" type="text/html" title="Drupal 10: Getting Config Ignore and Webform to play nice together" /><published>2025-06-05T00:00:00+00:00</published><updated>2025-06-05T00:00:00+00:00</updated><id>https://blog.karimratib.me/2025/06/05/drupal-config-ignore-webform</id><content type="html" xml:base="https://blog.karimratib.me/2025/06/05/drupal-config-ignore-webform.html"><![CDATA[<p>In my role as Systems Architect, I devote a lot of effort to configuration management. In Drupal-land, this means making sure that the site’s configuration synchronization runs smoothly and idempotently, across all deployment stages. We’ve been using a <code class="language-plaintext highlighter-rouge">drush</code>-based deployment sequence that has served us well across the many sites that we maintain. Here’s the magic incantation:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>drush cr<span class="p">;</span> drush updb <span class="nt">-y</span><span class="p">;</span> drush cim <span class="nt">-y</span><span class="p">;</span> drush deploy:hook <span class="nt">-y</span><span class="p">;</span> drush cr<span class="p">;</span>
</code></pre></div></div>
<p>This little sequence allows us to reliably update Drupal core, the site configuration and our own custom modules without running into dependency loops. <a href="https://www.drupal.org/docs/drupal-apis/configuration-api">Drupal’s Configuration API</a> is a very well-designed system that has greatly simplified this process since Drupal 8, especially with its plugin-based architecture that allows contrib modules to fine-tune the process.</p>

<p>For us, the <a href="https://www.drupal.org/project/config_ignore">Config Ignore contrib module</a> is invaluable because business users typically require control over <em>some aspects</em> the site’s configuration, typically when it comes to end-user-facing settings like labels and titles. By using Config Ignore’s excellent support for wildcards, individual subkeys and exclusion operator, we have a powerful toolset to give business users what they need.</p>

<h2 id="overriding-config_ignoresettings-in-settingsphp">Overriding <code class="language-plaintext highlighter-rouge">config_ignore.settings</code> in <code class="language-plaintext highlighter-rouge">settings.php</code></h2>
<p>During development, it’s common to want to override the official configuration with different settings. The usual approach is to use the <code class="language-plaintext highlighter-rouge">settings.local.php</code> file with a hard-coded <code class="language-plaintext highlighter-rouge">$config</code> entry - in our case <code class="language-plaintext highlighter-rouge">$config['config_ignore.settings']</code>. However, I quickly discovered that these overridden settings don’t get picked up by Config Ignore! Here we go, a new debugging dive 🤿… It turns out that <a href="https://git.drupalcode.org/project/config_ignore/-/blob/149db17d375e78ec79245d08a71a062953dbc8c3/src/EventSubscriber/ConfigIgnoreEventSubscriber.php#L141-155">the default Drupal config factory is only consulted if the <code class="language-plaintext highlighter-rouge">config_ignore.settings</code> entry is NOT present in the sync folder</a>. I am pretty sure this is the opposite of the usual expectation, and I may submit an issue to discuss that. In the meantime, here’s a small workaround that will pick up your overridden settings:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// my_custom_module.module</span>
<span class="kn">use</span> <span class="nf">Drupal\config_ignore</span><span class="nc">\ConfigIgnoreConfig</span><span class="p">;</span>

<span class="cd">/**
 * Implements hook_config_ignore_ignored_alter().
 */</span>
<span class="k">function</span> <span class="n">my_custom_module_config_ignore_ignored_alter</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$ignoreConfig</span><span class="p">)</span> <span class="p">{</span>
  <span class="nv">$override</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">config</span><span class="p">(</span><span class="s1">'config_ignore.settings'</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$override</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span>
      <span class="nv">$ignoreConfig</span> <span class="o">=</span> <span class="nc">ConfigIgnoreConfig</span><span class="o">::</span><span class="nf">fromConfig</span><span class="p">(</span><span class="nv">$override</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">catch</span> <span class="p">(</span><span class="nc">\Throwable</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
      <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">logger</span><span class="p">(</span><span class="s1">'my_custom_module'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">error</span><span class="p">(</span><span class="s1">'Invalid value for config_ignore.settings override. Ignoring.'</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>⚠️ BUT! How to <em>remove</em> config entries, instead of adding them? Consider the following <code class="language-plaintext highlighter-rouge">config_ignore.settings.yml</code> file in your config sync:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config_ignore.settings.yml</span>
<span class="na">mode</span><span class="pi">:</span> <span class="s">simple</span>
<span class="na">ignored_config_entities</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">mimemail.settings</span>
  <span class="pi">-</span> <span class="s">openid_connect.settings</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">openid_connect.settings.*'</span>
  <span class="pi">-</span> <span class="s">system.maintenance</span>
  <span class="pi">-</span> <span class="s">system.performance</span>
  <span class="pi">-</span> <span class="s">system.site</span>
  <span class="pi">-</span> <span class="s">update.settings</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.abilities_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.interests_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.learning_styles_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.multiple_intelligences_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.work_preferences_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.work_values_quiz:third_party_settings.my_custom_module.*'</span>
</code></pre></div></div>
<p>What happens if you declare the following override in your <code class="language-plaintext highlighter-rouge">settings.local.php</code>:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// settings.local.php</span>
<span class="c1">// INCORRECT VERSION!</span>
<span class="nv">$config</span><span class="p">[</span><span class="s1">'config_ignore.settings'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
  <span class="s1">'mode'</span> <span class="o">=&gt;</span> <span class="s1">'simple'</span><span class="p">,</span>
  <span class="s1">'ignored_config_entities'</span> <span class="o">=&gt;</span> <span class="p">[</span>
    <span class="s1">'mimemail.settings'</span><span class="p">,</span>
    <span class="s1">'openid_connect.settings'</span><span class="p">,</span>
    <span class="s1">'openid_connect.settings.*'</span><span class="p">,</span>
    <span class="s1">'system.maintenance'</span><span class="p">,</span>
    <span class="s1">'system.performance'</span><span class="p">,</span>
    <span class="c1">// 'system.site',</span>
    <span class="s1">'update.settings'</span><span class="p">,</span>
    <span class="c1">// 'webform.webform.abilities_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.interests_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.learning_styles_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.multiple_intelligences_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.work_preferences_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.work_values_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="p">]</span>
<span class="p">];</span>
</code></pre></div></div>
<p>Will <code class="language-plaintext highlighter-rouge">system.site</code> and the <code class="language-plaintext highlighter-rouge">webform.webform.*</code> be now kept out of the ignore list? ❌ NO!! As per the module code linked above, the <code class="language-plaintext highlighter-rouge">$config</code> array is <strong>merged</strong> with the original, resulting in the original bottom keys being kept. In order to truly override the settings, you would write the <code class="language-plaintext highlighter-rouge">$config</code> array to contain at least as many entries as the original:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// settings.local.php</span>
<span class="nv">$config</span><span class="p">[</span><span class="s1">'config_ignore.settings'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
  <span class="s1">'mode'</span> <span class="o">=&gt;</span> <span class="s1">'simple'</span><span class="p">,</span>
  <span class="s1">'ignored_config_entities'</span> <span class="o">=&gt;</span> <span class="p">[</span>
    <span class="s1">'mimemail.settings'</span><span class="p">,</span>
    <span class="s1">'openid_connect.settings'</span><span class="p">,</span>
    <span class="s1">'openid_connect.settings.*'</span><span class="p">,</span>
    <span class="s1">'system.maintenance'</span><span class="p">,</span>
    <span class="s1">'system.performance'</span><span class="p">,</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'system.site',</span>
    <span class="s1">'update.settings'</span><span class="p">,</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.abilities_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.interests_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.learning_styles_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.multiple_intelligences_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.work_preferences_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span> <span class="c1">// 'webform.webform.work_values_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="p">]</span>
<span class="p">];</span>
</code></pre></div></div>
<p>Now we are correctly overriding Config Ignore settings :tada:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/config-ignore-ignore.jpg">
      <img src="https://blog.karimratib.me/assets/config-ignore-ignore.jpg" style="max-width: 100%;" alt="Is that recursive enough for you?" />
    </a>
    <figcaption>Is that recursive enough for you?</figcaption>
  </figure>
</div>

<h2 id="ignoring-webform-element-titles">Ignoring Webform element titles</h2>
<p>With this out of the way, let’s go back to the initial business requirement: Allowing admin users to modify webform element titles without these changes getting reverted during the next config sync.</p>

<p>Here’s what a typical webform config looks like:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># webform.webform.interests_quiz.yml</span>
<span class="na">langcode</span><span class="pi">:</span> <span class="s">en</span>
<span class="na">status</span><span class="pi">:</span> <span class="s">open</span>
<span class="na">dependencies</span><span class="pi">:</span>
  <span class="na">module</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">webformautosave</span>
<span class="na">third_party_settings</span><span class="pi">:</span>
  <span class="na">webformautosave</span><span class="pi">:</span>
    <span class="na">auto_save</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">auto_save_time</span><span class="pi">:</span> <span class="m">5000</span>
    <span class="na">optimistic_locking</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">weight</span><span class="pi">:</span> <span class="m">0</span>
<span class="na">open</span><span class="pi">:</span> <span class="kc">null</span>
<span class="na">close</span><span class="pi">:</span> <span class="kc">null</span>
<span class="na">uid</span><span class="pi">:</span> <span class="m">1</span>
<span class="na">template</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">archive</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">id</span><span class="pi">:</span> <span class="s">interests_quiz</span>
<span class="na">title</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Interests</span><span class="nv"> </span><span class="s">Quiz'</span>
<span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">'</span>
<span class="na">categories</span><span class="pi">:</span> <span class="pi">{</span>  <span class="pi">}</span>
<span class="na">elements</span><span class="pi">:</span> <span class="pi">|-</span>
  <span class="s">page_1:</span>
    <span class="s">'#type': webform_wizard_page</span>
    <span class="s">'#title': 'Page 1'</span>
    <span class="s">'#prev_button_label': Back</span>
    <span class="s">'#next_button_label': Next</span>
    <span class="s">i_would_like_to_building_kitchen_cabinets:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I like building kitchen cabinets.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Realistic</span>
      <span class="s">'#required': true</span>
    <span class="s">i_would_enjoy_laying_brick_or_tile:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I would enjoy laying brick or tile.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Realistic</span>
      <span class="s">'#required': true</span>
    <span class="s">i_would_like_to_develop_a_new_medicine:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I would like to develop a new medicine.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Investigative</span>
      <span class="s">'#required': true</span>
<span class="pi">[</span><span class="nv">...</span><span class="pi">]</span>
</code></pre></div></div>
<p>As you can see, there’s no individual YAML key for each element title - instead, all elements are stored together in the <code class="language-plaintext highlighter-rouge">elements</code> key, with each element title specified in a <code class="language-plaintext highlighter-rouge">#title</code> subentry. How to ignore these <code class="language-plaintext highlighter-rouge">#title</code> entries while keeping the rest of the <code class="language-plaintext highlighter-rouge">elements</code> under config sync?</p>

<p>I don’t know about you, but the thought of hacking Drupal Configuration API + Config Ignore to handle synchronization of array sub-entries does not sound like a productive approach to me. Instead, I decided to reuse Webform’s Third Party Settings mechanism to store entries for each element label individually, and apply those labels instead of the originals during rendering. Here’s how the webform config would then look:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># webform.webform.interests_quiz.yml</span>
<span class="na">langcode</span><span class="pi">:</span> <span class="s">en</span>
<span class="na">status</span><span class="pi">:</span> <span class="s">open</span>
<span class="na">dependencies</span><span class="pi">:</span>
  <span class="na">module</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">webformautosave</span>
    <span class="pi">-</span> <span class="s">my_custom_module</span> <span class="c1"># THIS IS NEW</span>
<span class="na">third_party_settings</span><span class="pi">:</span>
  <span class="na">webformautosave</span><span class="pi">:</span>
    <span class="na">auto_save</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">auto_save_time</span><span class="pi">:</span> <span class="m">5000</span>
    <span class="na">optimistic_locking</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">my_custom_module</span><span class="pi">:</span> <span class="c1"># THIS IS NEW</span>
    <span class="na">i_would_like_to_building_kitchen_cabinets</span><span class="pi">:</span> <span class="s1">'</span><span class="s">I</span><span class="nv"> </span><span class="s">REALLY</span><span class="nv"> </span><span class="s">💙</span><span class="nv"> </span><span class="s">building</span><span class="nv"> </span><span class="s">kitchen</span><span class="nv"> </span><span class="s">cabinets.'</span>
    <span class="na">i_would_enjoy_laying_brick_or_tile</span><span class="pi">:</span>
    <span class="na">i_would_like_to_develop_a_new_medicine</span><span class="pi">:</span> <span class="s1">'</span><span class="s">I</span><span class="nv"> </span><span class="s">would</span><span class="nv"> </span><span class="s">like</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">develop</span><span class="nv"> </span><span class="s">a</span><span class="nv"> </span><span class="s">new</span><span class="nv"> </span><span class="s">medicine</span><span class="nv"> </span><span class="s">and</span><span class="nv"> </span><span class="s">make</span><span class="nv"> </span><span class="s">💰💰💰.'</span>
<span class="na">weight</span><span class="pi">:</span> <span class="m">0</span>
<span class="na">open</span><span class="pi">:</span> <span class="kc">null</span>
<span class="na">close</span><span class="pi">:</span> <span class="kc">null</span>
<span class="na">uid</span><span class="pi">:</span> <span class="m">1</span>
<span class="na">template</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">archive</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">id</span><span class="pi">:</span> <span class="s">interests_quiz</span>
<span class="na">title</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Interests</span><span class="nv"> </span><span class="s">Quiz'</span>
<span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">'</span>
<span class="na">categories</span><span class="pi">:</span> <span class="pi">{</span>  <span class="pi">}</span>
<span class="na">elements</span><span class="pi">:</span> <span class="pi">|-</span>
  <span class="s">page_1:</span>
    <span class="s">'#type': webform_wizard_page</span>
    <span class="s">'#title': 'Page 1'</span>
    <span class="s">'#prev_button_label': Back</span>
    <span class="s">'#next_button_label': Next</span>
    <span class="s">i_would_like_to_building_kitchen_cabinets:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I like building kitchen cabinets.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Realistic</span>
      <span class="s">'#required': true</span>
    <span class="s">i_would_enjoy_laying_brick_or_tile:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I would enjoy laying brick or tile.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Realistic</span>
      <span class="s">'#required': true</span>
    <span class="s">i_would_like_to_develop_a_new_medicine:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I would like to develop a new medicine.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Investigative</span>
      <span class="s">'#required': true</span>
</code></pre></div></div>
<p>With this in place, it’s now trivial to add the third party settings to <code class="language-plaintext highlighter-rouge">config_ignore.settings</code>, as we’ve seen above:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config_ignore.settings.yml</span>
<span class="na">mode</span><span class="pi">:</span> <span class="s">simple</span>
<span class="na">ignored_config_entities</span><span class="pi">:</span>
  <span class="pi">[</span><span class="nv">..</span><span class="pi">]</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.interests_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">[</span><span class="nv">..</span><span class="pi">]</span>
</code></pre></div></div>
<p>Here’s the code needed to create the new title settings:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// my_custom_module.module</span>

<span class="cd">/**
 * Implements hook_webform_third_party_settings_form_alter().
 */</span>
<span class="k">function</span> <span class="n">my_custom_module_webform_third_party_settings_form_alter</span><span class="p">(</span><span class="kt">array</span> <span class="o">&amp;</span><span class="nv">$form</span><span class="p">,</span> <span class="kt">FormStateInterface</span> <span class="nv">$form_state</span><span class="p">)</span> <span class="p">{</span>
  <span class="cd">/** @var \Drupal\webform\WebformInterface $webform */</span>
  <span class="nv">$webform</span> <span class="o">=</span> <span class="nv">$form_state</span><span class="o">-&gt;</span><span class="nf">getFormObject</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getEntity</span><span class="p">();</span>

  <span class="c1">// Add an entry for the title of each element.</span>
  <span class="nv">$questions</span> <span class="o">=</span> <span class="nb">array_filter</span><span class="p">(</span><span class="nv">$webform</span><span class="o">-&gt;</span><span class="nf">getElementsInitializedAndFlattened</span><span class="p">(),</span> <span class="n">some_condition_function</span><span class="p">);</span>
  <span class="k">foreach</span> <span class="p">(</span><span class="nv">$questions</span> <span class="k">as</span> <span class="nv">$key</span> <span class="o">=&gt;</span> <span class="nv">$question</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$form</span><span class="p">[</span><span class="s1">'third_party_settings'</span><span class="p">][</span><span class="s1">'my_custom_module'</span><span class="p">][</span><span class="nv">$key</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s1">'#type'</span> <span class="o">=&gt;</span> <span class="s1">'textfield'</span><span class="p">,</span>
      <span class="s1">'#title'</span> <span class="o">=&gt;</span> <span class="nf">t</span><span class="p">(</span><span class="s1">'Override: @question'</span><span class="p">,</span> <span class="p">[</span><span class="s1">'@question'</span> <span class="o">=&gt;</span> <span class="nv">$question</span><span class="p">[</span><span class="s1">'#title'</span><span class="p">]]),</span>
      <span class="s1">'#required'</span> <span class="o">=&gt;</span> <span class="kc">false</span><span class="p">,</span>
      <span class="s1">'#default_value'</span> <span class="o">=&gt;</span> <span class="nv">$webform</span><span class="o">-&gt;</span><span class="nf">getThirdPartySetting</span><span class="p">(</span><span class="s1">'my_custom_module'</span><span class="p">,</span> <span class="nv">$key</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span>
    <span class="p">];</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>And here’s a rudimentary way to display them:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// my_custom_module.module</span>

<span class="cd">/**
 * Implements template_preprocess_fieldset().
 */</span>
<span class="k">function</span> <span class="n">my_custom_module_preprocess_fieldset</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$variables</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="k">isset</span><span class="p">(</span><span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#webform'</span><span class="p">]))</span> <span class="p">{</span>
    <span class="cd">/** @var \Drupal\webform\WebformInterface $webform */</span>
    <span class="nv">$webform</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">entityTypeManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getStorage</span><span class="p">(</span><span class="s1">'webform'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">load</span><span class="p">(</span><span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#webform'</span><span class="p">]);</span>

    <span class="c1">// Override the element title with the corresponding third party setting.</span>
    <span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#title'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$webform</span><span class="o">-&gt;</span><span class="nf">getThirdPartySetting</span><span class="p">(</span><span class="s1">'my_custom_module'</span><span class="p">,</span> <span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#webform_key'</span><span class="p">],</span> <span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#title'</span><span class="p">]);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<div class="flex-center">
  <figure class="image">
    <a href="/assets/config-ignore-webform.png">
      <img src="https://blog.karimratib.me/assets/config-ignore-webform.png" style="max-width: 100%;" alt="The webform with overridden element titles." />
    </a>
    <figcaption>The webform with overridden element titles.</figcaption>
  </figure>
</div>

<p>Et voilà ! Happy site builders and happy business users 👷‍♀️🤝🤵‍♀️</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[I describe a technique for ignoring some, not all, webform settings from config sync. This gives flexibility to business users to manage end-user-facing form labels without fully giving up on configuration management. Along the way, I solve a quirk in Config Ignore that prevents from hard-coding its own configuration in settings.php.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/ignore-config-ignore.jpg" /><media:content medium="image" url="https://blog.karimratib.me/assets/ignore-config-ignore.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: Fully local, open source Drupal AI setup, part 1: Search API</title><link href="https://blog.karimratib.me/2025/05/28/drupal-ai-open-source.html" rel="alternate" type="text/html" title="Drupal 10: Fully local, open source Drupal AI setup, part 1: Search API" /><published>2025-05-28T00:00:00+00:00</published><updated>2025-05-28T00:00:00+00:00</updated><id>https://blog.karimratib.me/2025/05/28/drupal-ai-open-source</id><content type="html" xml:base="https://blog.karimratib.me/2025/05/28/drupal-ai-open-source.html"><![CDATA[<table class="changelog">
  <thead>
    <th>Changelog</th>
    <th></th>
  </thead>
  <tbody>


  <tr>
    
    
      <td>Jul 29, 2025 </td>
    
      <td> Updated for the latest versions of the Drupal AI modules.</td>
    
  </tr>

  </tbody>
</table>

<p>A recent interaction on the <a href="https://drupal.slack.com">Drupal community’s Slack</a> prompted me to describe the work I’ve been doing to create a fully local, open source setup for Drupal AI tools. My use case is to provide relevant search results based on natural language (English) queries. There are deployment scenarios, such as government projects, where the full system needs to be deployed in the home country and to avoid communicating with API services located elsewhere - this is the scenario that interests me here. Since I received positive feedback on my system description, I thought I’d clean it up and share it here. Hope it helps someone!</p>

<h2 id="theory-of-operation">Theory of operation</h2>
<p>The general idea of using Search API with natural language queries is to create vector embeddings of the relevant content, which are then matched against the embedding of the incoming user query. Vector embeddings are computed by an LLM (Large Language Model, in case you just landed on our planet) that is served by a local instance of <a href="https://ollama.com/">Ollama</a> running on my CPU-only laptop. At this time, I am using the LLM <a href="https://ollama.com/library/mxbai-embed-large"><code class="language-plaintext highlighter-rouge">mxbai-embed-large</code></a> to generate the embedding vectors. These vectors are stored in the same database as Drupal - I always use PostgreSQL and its <a href="https://github.com/pgvector/pgvector">pgvector extension</a> turns it into a perfectly acceptable vector database. The pretty amazing <a href="https://project.pages.drupalcode.org/ai/">Drupal AI ecosystem</a> supports these tools out of the box, so there’s almost no coding involved in this setup. Drupal AI even provides a Search API connector that is able to perform vector indexing within the familiar Drupal search infrastructure.</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api-workflow.svg">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api-workflow-dark.svg" style="max-width: 100%;" alt="Indexing and querying using Drupal AI + Search API." />
    </a>
    <figcaption>Indexing and querying using Drupal AI + Search API.</figcaption>
  </figure>
</div>

<p>I’ll be illustrating this setup with content from <a href="https://workbc.ca">WorkBC.ca</a>, a large Drupal site for the Ministry of Post-Secondary Education and Future Skills, British Columbia, that my team and I have been building and maintaining for the past 2+ years. The content describes the <a href="https://www.workbc.ca/plan-career/explore-careers">500+ official careers</a> that are identified by the Federal Government of Canada as representing the Canadian workforce.</p>

<h2 id="docker-setup">Docker setup</h2>
<p>I always start with Docker Compose. I use the excellent <a href="https://wodby.com/stacks/drupal10">Wodby Drupal stack</a> as a starting point - it includes all needed services and has intelligent defaults. In my case, I want to add <code class="language-plaintext highlighter-rouge">ollama</code> to the services, as well as inject the <code class="language-plaintext highlighter-rouge">pgvector</code> extension into Postgres - here are the relevant bits:</p>
<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># docker-compose.yml</span>
<span class="na">services</span><span class="pi">:</span>
  <span class="na">ollama</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ollama/ollama:${OLLAMA_TAG}</span>
    <span class="na">ports</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s2">"</span><span class="s">11434:11434"</span>
    <span class="na">volumes</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">/usr/share/ollama/.ollama:/root/.ollama</span> <span class="c1"># To store models on my local host</span>
  <span class="na">postgres</span><span class="pi">:</span>
    <span class="na">build</span><span class="pi">:</span>
      <span class="na">context</span><span class="pi">:</span> <span class="s">.</span>
      <span class="na">dockerfile</span><span class="pi">:</span> <span class="s">pgvector.Dockerfile</span>
      <span class="na">args</span><span class="pi">:</span>
        <span class="na">POSTGRES_TAG</span><span class="pi">:</span> <span class="s">${POSTGRES_TAG}</span>
        <span class="na">PGVECTOR_TAG</span><span class="pi">:</span> <span class="s">${PGVECTOR_TAG}</span>
</code></pre></div></div>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># pgvector.Dockerfile</span>
<span class="k">ARG</span><span class="s"> POSTGRES_TAG</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">wodby/postgres:${POSTGRES_TAG}</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">pgvector-builder</span>
<span class="k">ARG</span><span class="s"> PGVECTOR_TAG</span>
<span class="k">RUN </span>apk add git
<span class="k">RUN </span>apk add build-base
<span class="k">RUN </span>apk add clang
<span class="k">RUN </span>apk add llvm-dev
<span class="k">WORKDIR</span><span class="s"> /home</span>
<span class="k">RUN </span>git clone <span class="nt">--branch</span> v<span class="k">${</span><span class="nv">PGVECTOR_TAG</span><span class="k">}</span> https://github.com/pgvector/pgvector.git
<span class="k">WORKDIR</span><span class="s"> /home/pgvector</span>
<span class="k">RUN </span>make
<span class="k">RUN </span>make <span class="nb">install</span>

<span class="k">FROM</span><span class="s"> wodby/postgres:${POSTGRES_TAG}</span>
<span class="k">COPY</span><span class="s"> --from=pgvector-builder /usr/local/lib/postgresql/bitcode/vector.index.bc /usr/local/lib/postgresql/bitcode/vector.index.bc</span>
<span class="k">COPY</span><span class="s"> --from=pgvector-builder /usr/local/lib/postgresql/vector.so /usr/local/lib/postgresql/vector.so</span>
<span class="k">COPY</span><span class="s"> --from=pgvector-builder /usr/local/share/postgresql/extension /usr/local/share/postgresql/extension</span>
</code></pre></div></div>
<p>We are now ready to download the embedding model:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker-compose run ollama
docker-compose <span class="nb">exec </span>ollama ollama pull mxbai-embed-large:latest <span class="c"># in a different console</span>
</code></pre></div></div>
<h2 id="drupal-modules-setup">Drupal modules setup</h2>
<p>Here is the relevant configuration in my <code class="language-plaintext highlighter-rouge">composer.json</code> file:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"require"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"drupal/ai"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.2@alpha"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"drupal/ai_provider_ollama"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.1@beta"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"drupal/ai_vdb_provider_postgres"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.0@alpha"</span><span class="p">,</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>and the enabled modules:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wodby@php.container:/var/www/html $ drush pml | grep AI
  AI                                            AI Core (ai)                                                                     Enabled    1.2.0-alpha1
  AI Providers                                  DropAI Provider (dropai_provider)                                                Disabled   1.2.0-alpha1
  AI                                            AI API Explorer (ai_api_explorer)                                                Enabled    1.2.0-alpha1
  AI Tools                                      AI Assistant API (ai_assistant_api)                                              Disabled   1.2.0-alpha1
  AI                                            AI Automators (ai_automators)                                                    Disabled   1.2.0-alpha1
  AI Tools                                      AI Chatbot (ai_chatbot)                                                          Disabled   1.2.0-alpha1
  AI                                            AI CKEditor integration (ai_ckeditor)                                            Disabled   1.2.0-alpha1
  AI                                            AI Content Suggestions (ai_content_suggestions)                                  Disabled   1.2.0-alpha1
  AI                                            AI ECA integration (ai_eca)                                                      Disabled   1.2.0-alpha1
  AI                                            AI External Moderation (Deprecated) (ai_external_moderation)                     Disabled   1.2.0-alpha1
  AI                                            AI Logging (ai_logging)                                                          Disabled   1.2.0-alpha1
  AI (Experimental)                             AI Search (ai_search)                                                            Enabled    1.2.0-alpha1
  AI                                            AI Translate (ai_translate)                                                      Disabled   1.2.0-alpha1
  AI                                            AI Validations (ai_validations)                                                  Disabled   1.2.0-alpha1
  AI Providers                                  Ollama Provider (ai_provider_ollama)                                             Enabled    1.1.0-beta2
  AI Vector Database Providers (Experimental)   Postgres VDB Provider (ai_vdb_provider_postgres)                                 Enabled    1.0.0-alpha2
</code></pre></div></div>
<h2 id="drupal-ai-setup">Drupal AI setup</h2>
<p>With the infrastructure ready, it’s now time to configure the Drupal AI components and wire them together. I’ll be using the Admin UI with URIs and screenshots to better illustrate the setup.</p>

<h4 id="adminconfigaiprovidersollama">/admin/config/ai/providers/ollama</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-providers-ollama.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-providers-ollama.png" style="max-width: 100%;" alt="Accessing the local Ollama service." />
    </a>
    <figcaption>Accessing the local Ollama service.</figcaption>
  </figure>
</div>

<h4 id="adminconfigaisettings">/admin/config/ai/settings</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-settings.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-settings.png" style="max-width: 100%;" alt="The only AI provider we need is the LLM for embeddings." />
    </a>
    <figcaption>The only AI provider we need is the LLM for embeddings.</figcaption>
  </figure>
</div>

<h4 id="adminconfigaivdb_providerspostgres">/admin/config/ai/vdb_providers/postgres</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-vdb-providers-postgres.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-vdb-providers-postgres.png" style="max-width: 100%;" alt="Accessing the Drupal PostgreSQL database running the pgvector extension." />
    </a>
    <figcaption>Accessing the Drupal PostgreSQL database running the pgvector extension.</figcaption>
  </figure>
</div>

<h4 id="adminconfigsearchsearch-api">/admin/config/search/search-api</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api.png" style="max-width: 100%;" alt="We will create a new Search API server along with its index." />
    </a>
    <figcaption>We will create a new Search API server along with its index.</figcaption>
  </figure>
</div>

<h4 id="adminconfigsearchsearch-apiserverragedit">/admin/config/search/search-api/server/rag/edit</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api-server.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api-server.png" style="max-width: 100%;" alt="The Search API server settings." />
    </a>
    <figcaption>The Search API server settings.</figcaption>
  </figure>
</div>

<p>Note the <strong>Vector Database Configuration &gt; Collection</strong> setting <code class="language-plaintext highlighter-rouge">search_api_rag</code> which is the name of a database table created to hold the vector embeddings.</p>

<h4 id="adminconfigsearchsearch-apiindexcareer_profiles_ragedit">/admin/config/search/search-api/index/career_profiles_rag/edit</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api-index.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api-index.png" style="max-width: 100%;" alt="The Search API index settings." />
    </a>
    <figcaption>The Search API index settings.</figcaption>
  </figure>
</div>

<h4 id="adminconfigsearchsearch-apiindexcareer_profiles_ragfields">/admin/config/search/search-api/index/career_profiles_rag/fields</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api-fields.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api-fields.png" style="max-width: 100%;" alt="The Search API index field settings." />
    </a>
    <figcaption>The Search API index field settings.</figcaption>
  </figure>
</div>

<p>The tricky bit here is understanding the <strong>Indexing option</strong> setting - namely, the difference between <strong>Contextual content</strong> and <strong>Main content</strong> for indexing purposes. During indexing, each content item is divided into several chunks whose size is chosen to generate vector embeddings that accurately reflect its semantic meaning without overwhelming the LLM’s context window limit. For example, the open source vector database <a href="https://milvus.io/ai-quick-reference/what-is-the-optimal-chunk-size-for-rag-applications">Milvus mentions a chunk size of 128-512 tokens</a> - a token roughly corresponds to subwords of ~4 characters. But by subdividing the item, some chunks may miss the information that reflects the nature of the overall content item. The solution offered here is to repeat some fields in all the chunks - such fields are labeled as <strong>Contextual</strong>, whereas the <strong>Main</strong> content is the one being subdivided.</p>

<p>:warning: A word of caution: This is probably the part that requires the most tweaking to get good search results!!</p>

<h2 id="results">Results</h2>

<p>Once we’ve indexed the content in the index above, we’re ready to test the search:</p>

<h4 id="adminconfigaiexplorersvector_db_generator">/admin/config/ai/explorers/vector_db_generator</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-vector-db-generator.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-vector-db-generator.png" style="max-width: 100%;" alt="The Vector DB Explorer is useful to test your Drupal AI + Search API configuration." />
    </a>
    <figcaption>The Vector DB Explorer is useful to test your Drupal AI + Search API configuration.</figcaption>
  </figure>
</div>

<p>In case you’re curious, here’s the database table that the Search API server creates to store vector embeddings. You can notice that each node is broken up in several chunks, each starting with the node title which was selected as one of the <strong>Contextual</strong> fields above:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-vector-db.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-vector-db.png" style="max-width: 100%;" alt="The search_api_rag table contains the vector embeddings for content chunks." />
    </a>
    <figcaption>The search_api_rag table contains the vector embeddings for content chunks.</figcaption>
  </figure>
</div>

<p>This concludes part 1 of my Drupal AI setup. Next time, I’ll look at more specialized Search API use case before getting into the treacherous waters of generated responses. Happy vibing :robot:</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[I describe a Drupal AI setup based on open source tools and running locally. The use case is to provide search results based on natural language queries using the Search API ecosystem. The constraint is to avoid communicating with external APIs and rely only on services that are co-located with the Drupal deployment.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/drupal-ai-search-api-workflow.png" /><media:content medium="image" url="https://blog.karimratib.me/assets/drupal-ai-search-api-workflow.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: Fix AJAX-related error with Views exposed forms</title><link href="https://blog.karimratib.me/2025/05/21/drupal-ajax-views-exposed-filters.html" rel="alternate" type="text/html" title="Drupal 10: Fix AJAX-related error with Views exposed forms" /><published>2025-05-21T00:00:00+00:00</published><updated>2025-05-21T00:00:00+00:00</updated><id>https://blog.karimratib.me/2025/05/21/drupal-ajax-views-exposed-filters</id><content type="html" xml:base="https://blog.karimratib.me/2025/05/21/drupal-ajax-views-exposed-filters.html"><![CDATA[<p>I don’t mind fixing the bugs that I or my team introduce into our codebase - those bugs are expected and par for the course. But bugs in Drupal core are totally unacceptable!! /s</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/what-me-entitled.jpg">
      <img src="https://blog.karimratib.me/assets/what-me-entitled.jpg" style="max-width: 100%;" alt="In reality, these blog posts are just excuses for me to make more silly memes." />
    </a>
    <figcaption>In reality, these blog posts are just excuses for me to make more silly memes.</figcaption>
  </figure>
</div>

<p>This one was pretty confusing. I needed to dynamically update a drop-down every time a “parent” drop-down changed (think 2-level taxonomy vocabulary), which is a <a href="https://www.drupal.org/docs/develop/drupal-apis/javascript-api/ajax-forms">well-documented feature in the Forms API</a>. In a nutshell, the parent element gets an <code class="language-plaintext highlighter-rouge">#ajax</code> callback that is called upon user interaction, and that returns the updated child element from the <code class="language-plaintext highlighter-rouge">$form</code> structure. The Drupal AJAX frontend code takes care of replacing the child element in the HTML form. Neat and simple. In my case, though, I needed this behaviour in a Views exposed form, and that’s when the trouble started. When changing the parent element, the callback was not being called, and instead, an unrelated error was displayed, saying <code class="language-plaintext highlighter-rouge">An unrecoverable error occurred. The uploaded file likely exceeded the maximum file size (XXX) that this server supports</code>.</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/views-ajax-exception.gif">
      <img src="https://blog.karimratib.me/assets/views-ajax-exception.gif" style="max-width: 100%;" alt="Hi Drupal, which uploaded file are you talking about? Source: Drupal user ajits." />
    </a>
    <figcaption>Hi Drupal, which uploaded file are you talking about? Source: Drupal user ajits.</figcaption>
  </figure>
</div>

<p>Fortunately, I was able to find <a href="https://www.drupal.org/project/drupal/issues/2658718">an existing issue</a> (submitted in Jan 2016 :sob:) which was useful to confirm I was not vastly misunderstanding the problem. The workarounds mentioned in this ticket did not work for me, though, so I had to keep digging on my own. Here’s the result of my analysis:</p>

<h2 id="why-this-error">Why this error?</h2>
<p>The displayed error has absolutely nothing to do with the situation at hand: There’s no uploaded file, and there’s not even a <code class="language-plaintext highlighter-rouge">POST</code>‘ed form, since the AJAX request uses the <code class="language-plaintext highlighter-rouge">GET</code> method. My approach to find the source of an error is to start by locating the text of the error in the codebase and work backwards up the call stack - in this case, it is thrown by <code class="language-plaintext highlighter-rouge">FormAjaxSubscriber::onException</code> which is itself triggered by <a href="https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Form%21FormBuilder.php/function/FormBuilder%3A%3AbuildForm/10"><code class="language-plaintext highlighter-rouge">FormBuilder::buildForm</code></a> under an unexpected condition:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="c1">// In case the post request exceeds the configured allowed size</span>
    <span class="c1">// (post_max_size), the post request is potentially broken. Add some</span>
    <span class="c1">// protection against that and at the same time have a nice error message.</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$ajax_form_request</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'form_id'</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nc">BrokenPostRequestException</span><span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getFileUploadMaxSize</span><span class="p">());</span>
    <span class="p">}</span>
</code></pre></div></div>
<p>I don’t know about you, but to me the condition of a missing <code class="language-plaintext highlighter-rouge">form_id</code> seems unrelated to a file limit issue. By examining the AJAX <code class="language-plaintext highlighter-rouge">GET</code> request in the browser, I was able to verify that no <code class="language-plaintext highlighter-rouge">form_id</code> query argument is actually sent - which means that further down this function, the form builder will be unable to find the form object that should be built. Looks like a legitimate error and the AJAX frontend seems to be at fault.</p>

<h2 id="the-workaround-needed-a-workaround">The workaround needed a workaround</h2>
<p>At this point, I had the choice of debugging and fixing the <a href="https://git.drupalcode.org/project/drupal/-/blob/10.5.x/core/misc/ajax.js">Drupal AJAX frontend code</a>, or find a workaround that would allow me to keep working on my business feature. Although I am a firm believer that we should allocate some of our professional time to contribute to the open source software that we use, this seemed a deeper dive than I could afford at that point. Instead, I opted for the most generic workaround that I could reuse in similar future scenarios. Here’s what I came up with:</p>

<p>The general idea is to simply send the missing <code class="language-plaintext highlighter-rouge">form_id</code> in the AJAX request. The <a href="https://www.drupal.org/docs/develop/drupal-apis/javascript-api/ajax-forms#s-full-list-of-available-ajax-properties">Form API <code class="language-plaintext highlighter-rouge">#ajax</code> properties</a> helpfully include a customizable <code class="language-plaintext highlighter-rouge">url</code> entry, so I decided to augment the current URL with the <code class="language-plaintext highlighter-rouge">form_id</code> query argument. Something like that, maybe?</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">my_module_form_views_exposed_form_alter</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$form</span><span class="p">,</span> <span class="nc">FormStateInterface</span> <span class="nv">$form_state</span><span class="p">,</span> <span class="nv">$form_id</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// DANGER: THIS WILL NOT WORK!</span>
    <span class="nv">$uri</span> <span class="o">=</span> <span class="nc">\Drupal\Component\Utility\UrlHelper</span><span class="o">::</span><span class="nf">parse</span><span class="p">(</span><span class="nc">\Drupal</span><span class="o">::</span><span class="nf">request</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getRequestUri</span><span class="p">());</span>
    <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">][</span><span class="s1">'form_id'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$form</span><span class="p">[</span><span class="s1">'#id'</span><span class="p">];</span>
    <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">][</span><span class="s1">'ajax_form'</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="nv">$form</span><span class="p">[</span><span class="s1">'my_parent_element'</span><span class="p">][</span><span class="s1">'#ajax'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s1">'callback'</span> <span class="o">=&gt;</span> <span class="s1">'my_parent_element_callback'</span><span class="p">,</span>
      <span class="s1">'wrapper'</span> <span class="o">=&gt;</span> <span class="s1">'my-parent-element-container'</span><span class="p">,</span>
      <span class="s1">'url'</span> <span class="o">=&gt;</span> <span class="nc">Url</span><span class="o">::</span><span class="nf">fromUri</span><span class="p">(</span><span class="s1">'internal:'</span> <span class="mf">.</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'path'</span><span class="p">],</span> <span class="p">[</span><span class="s1">'query'</span> <span class="o">=&gt;</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">],</span> <span class="s1">'fragment'</span> <span class="o">=&gt;</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'fragment'</span><span class="p">]]),</span>
    <span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>
<p>If only things were that simple! This did not work - the AJAX request kept missing ALL query arguments after this change. How on earth could <code class="language-plaintext highlighter-rouge">URL</code> options get ignored?? More hours, more digging revealed <a href="https://git.drupalcode.org/project/drupal/-/blob/10.5.x/core/lib/Drupal/Core/Render/Element/RenderElementBase.php#L381-388">this code deep inside <code class="language-plaintext highlighter-rouge">RenderElementBase::preRenderAjaxForm</code></a> - someone decided to overwrite the incoming URL options with those from another key THAT IS NOT EVEN DOCUMENTED :angry: - I’m sure it seemed like a good idea at the time and I’ve edited the documentation to reflect this quirk :angel:</p>

<p>So the final code looks like:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">my_module_form_views_exposed_form_alter</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$form</span><span class="p">,</span> <span class="nc">FormStateInterface</span> <span class="nv">$form_state</span><span class="p">,</span> <span class="nv">$form_id</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Override the AJAX request to include `form_id` and `ajax_form`.</span>
    <span class="nv">$uri</span> <span class="o">=</span> <span class="nc">\Drupal\Component\Utility\UrlHelper</span><span class="o">::</span><span class="nf">parse</span><span class="p">(</span><span class="nc">\Drupal</span><span class="o">::</span><span class="nf">request</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getRequestUri</span><span class="p">());</span>
    <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">][</span><span class="s1">'form_id'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$form</span><span class="p">[</span><span class="s1">'#id'</span><span class="p">];</span>
    <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">][</span><span class="s1">'ajax_form'</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="nv">$form</span><span class="p">[</span><span class="s1">'my_parent_element'</span><span class="p">][</span><span class="s1">'#ajax'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s1">'callback'</span> <span class="o">=&gt;</span> <span class="s1">'my_parent_element_callback'</span><span class="p">,</span>
      <span class="s1">'wrapper'</span> <span class="o">=&gt;</span> <span class="s1">'my-parent-element-container'</span><span class="p">,</span>
      <span class="s1">'url'</span> <span class="o">=&gt;</span> <span class="nc">Url</span><span class="o">::</span><span class="nf">fromUri</span><span class="p">(</span><span class="s1">'internal:'</span> <span class="mf">.</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'path'</span><span class="p">]),</span>
      <span class="s1">'options'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'query'</span> <span class="o">=&gt;</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">],</span> <span class="s1">'fragment'</span> <span class="o">=&gt;</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'fragment'</span><span class="p">]]</span>
    <span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And this, my friends, is how I fixed the file size limit error that occurs on AJAXified Views exposed form elements :tada:</p>

<h2 id="sober-concluding-thoughts">Sober concluding thoughts</h2>
<p>In a codebase as large as Drupal’s, it is normal to expect inconsistencies and edge cases. Since this is the second issue that involves Views exposed forms (the first one being an <a href="/2024/08/29/drupal-bigpipe-reset.html">unwanted interaction with Big Pipe</a>), I am now expecting more bugs to emanate from this area - namely, the intersection between Views exposed forms and advanced Drupal features. I wonder if anyone’s done an analysis of open Drupal issues to find clusters of bugs based on Drupal core components or recurring keywords.</p>

<p>In this particular case, the error that Drupal reports is not only useless, it is actively misleading. This is not particularly unusual either, as error handling is notoriously one of the harder aspects of programming, and much <a href="https://www.google.com/search?q=error+handling+in+software+development">virtual ink has been spilled to try to make sense of it</a>. What should be reported to the user? What should be logged? What should be handled silently? As a software architect who interacts a lot with business users, I can tell you that core Drupal has its own share of confusing and unhelpful error messages. The most egregious one for me is the infamous message <code class="language-plaintext highlighter-rouge">An illegal choice has been detected. Please contact the site administrator.</code> which only serves to confuse users but offers them no help. In our own software process, I make sure to review the errors thrown by the developers and ask myself the following questions in each case:</p>

<ul>
  <li><strong>UX (User eXperience)</strong>: Should end users see an error, a warning, or should the UI keep functioning silently? What information will best help end users to accomplish their task at hand?</li>
  <li><strong>DX (Developer eXperience)</strong>: Should site builders see an error, a warning, or should the application keep functioning silently? What information will best help site builders to develop the application?</li>
  <li><strong>DevOps</strong>: Should system engineers see an error, a warning, or should the system keep functioning silently? What information will best help system engineers to manage the site’s operation?</li>
</ul>

<p>The detail about the <code class="language-plaintext highlighter-rouge">URL</code> options being overridden by an undocumented <code class="language-plaintext highlighter-rouge">['#ajax']['options']</code> key not only illustrates the difficulty of keeping documentation in sync with the code, but also the importance of thinking about DX when designing APIs, to minimize surprises and inconsistencies which directly translate to bugs or wasted effort.</p>

<p>In the spirit of contributing back, I <a href="https://www.drupal.org/project/drupal/issues/2658718#comment-16099799">documented my workaround in the original issue</a> and updated the AJAX Forms documentation accordingly - hoping it will prevent further unnecessary hair pulling!</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[Enabling AJAX callbacks on Views exposed forms causes a cryptic error that "the uploaded file likely exceeded the maximum file size". In this post, I explain why this happens, and present a functioning workaround.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/what-me-entitled.jpg" /><media:content medium="image" url="https://blog.karimratib.me/assets/what-me-entitled.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Music Grimoire: 2024 progress report</title><link href="https://blog.karimratib.me/2024/10/01/music-grimoire-progress-report.html" rel="alternate" type="text/html" title="Music Grimoire: 2024 progress report" /><published>2024-10-01T00:00:00+00:00</published><updated>2024-10-01T00:00:00+00:00</updated><id>https://blog.karimratib.me/2024/10/01/music-grimoire-progress-report</id><content type="html" xml:base="https://blog.karimratib.me/2024/10/01/music-grimoire-progress-report.html"><![CDATA[<p>About 7 years ago, I had a flash of insight: Music software is strongly biased towards Western mainstream music, and most tools are programmed with the “axioms” of this music as their foundation. Things like 12 notes per octave, tuned to intervals that are specific to the 12-TET tuning, with predefined scales and modes - these are hard-coded into the lowest layers of most music software and make it almost impossible to express musical ideas outisde this framework.</p>

<p>I <a href="/2018/01/05/music-l10n.html">wrote a manifesto of sorts</a> about it and naively set off to code my way out of this situation. I was driven by my own musical interests: Rediscovering and arranging songs from the popular Arabic repertoire into modern idioms. Although I achieved <a href="https://musescore.com/user/55682/sets/2178286">a modest milestone towards that particular goal</a>, it opened up a universe of questions and possibilities about how music is computed, notated, played back. I have not stopped learning and coding in this space since then.</p>

<p>Here is a snapshot of where I am in this journey, and where (I think) I am headed.</p>

<h2 id="an-open-source-standards-based-unix-inspired-global-music-ecosystem">An open source, standards-based, Unix-inspired, global-music ecosystem</h2>
<p>At the core of my vision is an ecosystem of tools for publishing interactive musical ideas, ultimately delivered through the Web. The target audience includes music practitioners and institutions that are looking to publish interactive music material on the Web, like music teachers, university departments, cultural heritage institutions. Of course, Web-based music publishing already exists: It <em>is</em> possible to <a href="/2020/10/08/music-blogging.html">embed music scores via various platforms</a>. But these platforms are proprietary, commercial, unextensible, and Western-music-centric. To me, this feels too restrictive for something as important as music. I am aiming for something more inclusive.</p>

<p>To produce an open music publishing system for the Web, I need to build on open standards:</p>
<ul>
  <li><a href="https://www.w3.org/2021/06/musicxml40/">MusicXML</a> is the W3C format for music sheet exchange - based on XML that I’ve come to appreciate for its maturity and incredible ecosystem of tools.</li>
  <li><a href="https://midi.org/specs">MIDI</a> is the 40+ years old (and evolving) standard protocol for communicating musical devices - in our context, it’s used to both encode the music content that is converted from MusicXML (via MIDI files) and to produce the actual sound (via MIDI synths).</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API">Web Audio API</a> is the W3C API for producing audio within Web applications.</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_MIDI_API">Web MIDI API</a> is the W3C API for integrating Web applications with MIDI devices.</li>
  <li><a href="https://www.smufl.org/">SMuFL</a> is a Unicode extension to represent musical symbols, also part of the W3C Music Notation Community Group with maintains MusicXML.</li>
</ul>

<p>By careful adherence to these standards, music applications can be built to transcend the limiting assumptions of Western mainstream practice. In some cases, <a href="https://github.com/w3c/smufl/issues/44">it is necessary to get involved in tweaking the standards</a>, and in many cases, <a href="https://github.com/musescore/MuseScore/pull/6693">open source implementations of these standards are incomplete or incorrect and need fixing</a>.</p>

<p>The general workflow I have in mind for music publishing involves the following steps:</p>
<ul>
  <li>Express musical ideas into MusicXML (by explicit score writing or by generation from other sources)</li>
  <li>Convert MusicXML to MIDI for playback (with optional augmentation such as auto-generated accompaniments)</li>
  <li>Load MusicXML in a Web player for display using existing components such as <a href="https://opensheetmusicdisplay.org">OSMD</a>, <a href="https://verovio.org">Verovio</a>, etc.</li>
  <li>Play MIDI in the Web player via Web MIDI or Web Audio using existing components such as <a href="https://tonejs.github.io/">Tone.js</a>, <a href="https://surikov.github.io/webaudiofont/">WebAudioFont</a>, etc.</li>
</ul>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/music-workflow.svg">
      <img src="https://blog.karimratib.me/assets/music-workflow-dark.svg" style="max-width: 100%;" alt="The general music workflow from score production to playback with associated GitHub repos." />
    </a>
    <figcaption>The general music workflow from score production to playback with associated GitHub repos.</figcaption>
  </figure>
</div>

<p>What follows is a quick overview of the tools that I am maintaining to support this workflow.</p>

<h2 id="lead-sheets-to-musicxml">Lead sheets to MusicXML</h2>
<p>For pop/rock/jazz players, lead sheets are essential to convey the crux of a tune. To support the use case of playing lead sheets, I’ve created <a href="https://github.com/infojunkie/ireal-musicxml"><code class="language-plaintext highlighter-rouge">ireal-musicxml</code>, a library to convert iReal Pro lead sheets to MusicXML</a>. For example, this iReal Pro tune:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/9.20-special-ireal.jpg">
      <img src="https://blog.karimratib.me/assets/9.20-special-ireal.jpg" style="max-width: 50%;" alt="The original iReal Pro tune." />
    </a>
    <figcaption>The original iReal Pro tune.</figcaption>
  </figure>
</div>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/9.20-special-musescore.jpg">
      <img src="https://blog.karimratib.me/assets/9.20-special-musescore.jpg" style="max-width: 50%;" alt="MuseScore's rendering of &lt;code&gt;ireal-musicxml&lt;/code&gt;'s output." />
    </a>
    <figcaption>MuseScore's rendering of <code>ireal-musicxml</code>'s output.</figcaption>
  </figure>
</div>

<h2 id="musicxml-to-midi-with-accompaniment">MusicXML to MIDI with accompaniment</h2>
<p>To support conversion of MusicXML to MIDI, I’ve created <a href="https://github.com/infojunkie/musicxml-midi"><code class="language-plaintext highlighter-rouge">musicxml-midi</code>, a library and API server</a> that supports the addition of auto-generated accompaniments via <a href="https://www.mellowood.ca/mma/">another open source tool</a> that I’ve adopted and enhanced. Here’s how the same tune above is played back with accompaniment:</p>

<div class="section">
  <midi-player src="/assets/9.20-special.mid" sound-font="" visualizer="#dummy"></midi-player>
  <figcaption>
    Playback courtesy of
    <a href="https://cifkao.github.io/html-midi-player/"><code>html-midi-player</code></a>.
  </figcaption>
</div>

<h2 id="groove-to-musicxml">Groove to MusicXML</h2>
<p>This library also includes <a href="https://github.com/infojunkie/musicxml-midi/blob/main/src/js/musicxml-grooves.js"><code class="language-plaintext highlighter-rouge">musicxml-grooves</code>, a tool to convert raw “grooves” (i.e. accompaniment patterns) into MusicXML sheets</a>. This is how the following MIDI groove is interpreted by MuseScore and by <code class="language-plaintext highlighter-rouge">musicxml-grooves</code>:</p>

<div class="section">
  <midi-player src="/assets/JazzBasieA.mid" sound-font="" visualizer="#dummy"></midi-player>
  <figcaption>
    Playback courtesy of
    <a href="https://cifkao.github.io/html-midi-player/"><code>html-midi-player</code></a>.
  </figcaption>
</div>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/JazzBasieA-musescore.jpg">
      <img src="https://blog.karimratib.me/assets/JazzBasieA-musescore.jpg" style="max-width: 100%;" alt="The MIDI file as interpreted by MuseScore." />
    </a>
    <figcaption>The MIDI file as interpreted by MuseScore.</figcaption>
  </figure>
</div>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/JazzBasieA.jpg">
      <img src="https://blog.karimratib.me/assets/JazzBasieA.jpg" style="max-width: 100%;" alt="The same pattern as interpreted by &lt;code&gt;musicxml-grooves&lt;/code&gt; (without post-editing). This version is more readable than the above because the converter tries hard to quantize the notes to a grid that includes triplets." />
    </a>
    <figcaption>The same pattern as interpreted by <code>musicxml-grooves</code> (without post-editing). This version is more readable than the above because the converter tries hard to quantize the notes to a grid that includes triplets.</figcaption>
  </figure>
</div>

<h2 id="musicxml-to-musescore">MusicXML to MuseScore</h2>
<p><a href="https://musescore.org">MuseScore</a> is one of the few serious open source music writing software, but it suffers from incomplete MusicXML import/export. I’ve recently started work on <a href="https://github.com/infojunkie/musicxml-mscx"><code class="language-plaintext highlighter-rouge">musicxml-mscx</code>, a new library to perform more robust MusicXML conversion to and from the native MuseScore format</a>.</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/tutorial-apres-un-reve.finale.jpg">
      <img src="https://blog.karimratib.me/assets/tutorial-apres-un-reve.finale.jpg" style="max-width: 100%;" alt="The original score, converted to MusicXML by Finale." />
    </a>
    <figcaption>The original score, converted to MusicXML by Finale.</figcaption>
  </figure>
</div>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/tutorial-apres-un-reve.jpg">
      <img src="https://blog.karimratib.me/assets/tutorial-apres-un-reve.jpg" style="max-width: 100%;" alt="&lt;code&gt;musicxml-mscx&lt;/code&gt;'s output to MuseScore format. Note that MuseScore does not support cross-staff beams at the logical level." />
    </a>
    <figcaption><code>musicxml-mscx</code>'s output to MuseScore format. Note that MuseScore does not support cross-staff beams at the logical level.</figcaption>
  </figure>
</div>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/tutorial-apres-un-reve.musescore.jpg">
      <img src="https://blog.karimratib.me/assets/tutorial-apres-un-reve.musescore.jpg" style="max-width: 100%;" alt="MuseScore's own MusicXML importer. Can you spot the differences between the 3 displays?" />
    </a>
    <figcaption>MuseScore's own MusicXML importer. Can you spot the differences between the 3 displays?</figcaption>
  </figure>
</div>

<h2 id="putting-it-all-together-a-web-based-audio-player">Putting it all together: A Web-based audio player</h2>
<p>Once the music assets are produced, they are ready to be loaded in a Web application. For this purpose, I’ve created <a href="https://github.com/infojunkie/musicxml-player"><code class="language-plaintext highlighter-rouge">musicxml-player</code>, a Web component that loads MusicXML and MIDI files</a>, in order to synchronize the audio playback with the animation of the music sheet. It’s an ambitious component that packages several 3rd-party modules into a flexible foundation to build Web-based music applications. Here are 2 video captures from the demo app in the component’s repo:</p>

<div class="section">
  <div class="flex-center">
    <video controls="" width="100%">
      <source src="/assets/baiao.webm" type="video/webm" />
    </video>
  </div>
  <figcaption>A video capture of a looping rhythm.</figcaption>
</div>

<div class="section">
  <div class="flex-center">
    <video controls="" width="100%">
      <source src="/assets/salma-ya-salama.webm" type="video/webm" />
    </video>
  </div>
  <figcaption>A video capture of a score playback with auto-generated accompaniment.</figcaption>
</div>

<h2 id="the-challenges-and-rewards-of-this-project">The challenges and rewards of this project</h2>
<p>Writing this post, I realize I came a long way since that original manifesto in 2018… and that the road ahead is arbitrarily long. To remain motivated, I keep challenging myself to mini-projects that tickle my immediate fancy, and I do my best to fit them within the general framework of this ecosystem. I am constantly faced with new questions, from deeply philosophical ones to immediate programming problems:</p>

<ul>
  <li>What are musical tunings, from a mathematical point of view? Why are the frequencies of the notes the way they are?</li>
  <li>What are the commonalities and differences between Western scales, Arabic maqams, Indian ragas, and musical modes from other world cultures?</li>
  <li>How to represent the musics of different cultures in a single programming system, without completely diluting the former and keeping the latter manageable?</li>
  <li>How to extract from MIDI events a sequence of notes that is understandable and playable by humans?</li>
  <li>How to reliably schedule notes from a MIDI file to be played in real-time on a Web page?</li>
  <li>How to link the information in a MusicXML score to the audio events in a MIDI file?</li>
  <li>What the heck is XSL and how can I write data transformations with it??</li>
</ul>

<p>Answering these questions is the reason why I am still motivated to go on! Over the course of the years, I’ve encountered some truly inspiring projects that have expanded my musicological mind. Here are a few:</p>

<ul>
  <li>Gareth Loy’s <a href="http://www.musimathics.com/">“Musimathics: A Guided Tour of the Mathematics of Music”</a></li>
</ul>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/musimathics.jpg">
      <img src="https://blog.karimratib.me/assets/musimathics.jpg" style="max-width: 100%;" alt="Musimathics blows my mind every time I pick it up 🤯" />
    </a>
    <figcaption>Musimathics blows my mind every time I pick it up 🤯</figcaption>
  </figure>
</div>

<ul>
  <li>Manuel Op de Coul’s <a href="https://www.huygens-fokker.org/scala/">Scala</a>, a mind-bogglingly comprehensive tool for exploring musical tunings.</li>
</ul>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/scala-keyboard.png">
      <img src="https://blog.karimratib.me/assets/scala-keyboard.png" style="max-width: 100%;" alt="One of countless feature-rich functions of Scala." />
    </a>
    <figcaption>One of countless feature-rich functions of Scala.</figcaption>
  </figure>
</div>

<ul>
  <li>Chris Wilson’s <a href="https://web.dev/articles/audio-scheduling">A tale of two clocks</a>, the seminal article about robust Web audio sequencing.</li>
</ul>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/a-tale-of-two-clocks.png">
      <img src="https://blog.karimratib.me/assets/a-tale-of-two-clocks.png" style="max-width: 100%;" alt="A wonderfully explanatory diagram that captures the essence of the technique." />
    </a>
    <figcaption>A wonderfully explanatory diagram that captures the essence of the technique.</figcaption>
  </figure>
</div>

<p>I’ve been fortunate to work with others who are interested in this domain: A year ago, I was sponsored to add a “horizontal scrolling” mode to the player, as well as a method to synchronize the playback with a YouTube video (hint: it uses the <a href="https://webtiming.github.io/timingobject/">Timing Object W3C draft specification</a>) - both of which went back into <code class="language-plaintext highlighter-rouge">musicxml-player</code>. Today, I am exploring adding multiplayer capability to the player, also using Web standards. I’m also fortunate to interact with like-minded developers, like <a href="https://media-codings.com/">Christoph Guttandin</a> who maintains a dizzying array of well-crafted audio modules - we collaborate on his excellent <a href="https://github.com/chrisguttandin/midi-player"><code class="language-plaintext highlighter-rouge">midi-player</code> component</a> which is a cornerstone of <code class="language-plaintext highlighter-rouge">musicxml-player</code>. Since the early days, I’ve been in touch with <a href="https://www.mellowood.ca">Bob van del Poel</a>, a fellow British Columbian who wrote the ridiculously great <a href="https://www.mellowood.ca/mma/">Musical MIDI Accompaniment (MMA)</a> system which is a cornerstone of <code class="language-plaintext highlighter-rouge">musicxml-midi</code>.</p>

<h2 id="looking-ahead-one-year">Looking ahead one year</h2>
<p>Here’s what I hope to work on within the next year:</p>

<ul>
  <li>
    <p>Embed playable music sheets into actual CMS systems, starting with my own <a href="https://musescore.com/user/55682/sets/2178286">Arabic Real Book sheets</a> - <a href="https://github.com/infojunkie/musicxml-player/issues/41">GitHub issue here</a>.</p>
  </li>
  <li>
    <p>Reach a milestone with <code class="language-plaintext highlighter-rouge">musicxml-mscx</code> to convert full music scores from MusicXML to MuseScore format - focusing on correctly handling the bugs in MuseScore’s own MusicXML import.</p>
  </li>
  <li>
    <p>Explore the feasibility of using pre-rendered scores in <code class="language-plaintext highlighter-rouge">musicxml-player</code> to replace resource-intensive JavaScript notation engines - <a href="https://github.com/infojunkie/musicxml-player/issues/38">GitHub issue here</a>.</p>
  </li>
  <li>
    <p>Replace my simplistic MIDI soft-synth in <code class="language-plaintext highlighter-rouge">musicxml-player</code> with a more complete one such as SpessaSynth - <a href="https://github.com/infojunkie/musicxml-player/issues/39">GitHub issue here</a>.</p>
  </li>
  <li>
    <p>Explore multiplayer playback in <code class="language-plaintext highlighter-rouge">musicxml-player</code> - <a href="https://github.com/infojunkie/musicxml-player/issues/40">GitHub issue here</a>.</p>
  </li>
  <li>
    <p>Support microtonality in MusicXML to MIDI conversion - <a href="https://github.com/infojunkie/musicxml-midi/issues/45">GitHub issue here</a>.</p>
  </li>
  <li>
    <p>Expand the groove conversion algorithm in <code class="language-plaintext highlighter-rouge">musicxml-grooves</code> to handle full MIDI files - <a href="https://github.com/infojunkie/musicxml-midi/issues/53">GitHub issue here</a>.</p>
  </li>
</ul>

<p>I hope to continue working on this project for a long time to come, and I welcome any and all contributions! :handshake:</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="music" /><summary type="html"><![CDATA[In this post, I present a summary of the music ecosystem I've been working on for the past 7 years.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/music-workflow.png" /><media:content medium="image" url="https://blog.karimratib.me/assets/music-workflow.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: Fix Views Reset button with Big Pipe</title><link href="https://blog.karimratib.me/2024/08/29/drupal-bigpipe-reset.html" rel="alternate" type="text/html" title="Drupal 10: Fix Views Reset button with Big Pipe" /><published>2024-08-29T00:00:00+00:00</published><updated>2024-08-29T00:00:00+00:00</updated><id>https://blog.karimratib.me/2024/08/29/drupal-bigpipe-reset</id><content type="html" xml:base="https://blog.karimratib.me/2024/08/29/drupal-bigpipe-reset.html"><![CDATA[<p>I was <strong>flabbergasted</strong> to discover that Big Pipe breaks the Views Reset button. In fact, Big Pipe breaks <strong>all</strong> form redirects. Not sure how other Drupal devs feel about that, but this was a big smh moment for me. Just imagine the collective time wasted debugging one’s code until one associates this failure to a core module bug!! :facepalm:</p>

<p>Now that my rant’s over, let’s get into the technical details of this story.</p>

<h2 id="detecting-the-bug">Detecting the bug</h2>
<p>The tell-tale sign that you hit this bug is when you enable the Reset button on a view’s exposed form, and instead of resetting the view filters, you get a blank page. The log says something like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Drupal\Core\Form\EnforcedResponseException: in Drupal\Core\Form\FormBuilder-&gt;buildForm() (line 357 of /var/www/html/web/core/lib/Drupal/Core/Form/FormBuilder.php)
#0 /var/www/html/web/core/modules/views/src/Plugin/views/exposed_form/ExposedFormPluginBase.php(134): Drupal\Core\Form\FormBuilder-&gt;buildForm()
#1 /var/www/html/web/core/modules/views/src/ViewExecutable.php(1243): Drupal\views\Plugin\views\exposed_form\ExposedFormPluginBase-&gt;renderExposedForm()
</code></pre></div></div>

<h2 id="solution-1-applying-the-patch">Solution 1: Applying the patch</h2>
<p>The <a href="https://www.drupal.org/project/drupal/issues/3304746">relevant bug report</a> has a patch that worked for me. I had to apply the patch manually to Drupal 9.x (please, don’t shoot me because I’m not in charge of our Drupal update schedule!!) but the code changes are exactly the same.</p>

<p>When you apply this patch, the Reset button works again. But clumsily: First, you see the URL changing to your current filters followed by <code class="language-plaintext highlighter-rouge">&amp;op=Reset</code>, then the browser redirects to the page’s bare URL, thereby resetting the filters. This is of course a consequence of using Big Pipe, which optimizes page rendering by returning all cached blocks first, and deferring uncacheable blocks to be requested by the front-end. A marvel of engineering by <strong>Wim Leers</strong>! Still, the flickering leaves to be desired.</p>

<p>In my case, this particular view is the principal component of the page, so I feel OK disabling Big Pipe for just this page if at all possible. But how?</p>

<h2 id="solution-2a-disable-big-pipe-for-a-specific-route">Solution 2a: Disable Big Pipe for a specific route</h2>
<p>The standard approach to disabling Big Pipe is to inject the setting <code class="language-plaintext highlighter-rouge">_no_big_pipe: TRUE</code> in the options of the relevant route. If your page’s route is unique, then all you need is to follow the official guide on <a href="https://www.drupal.org/docs/drupal-apis/routing-system/altering-existing-routes-and-adding-new-routes-based-on-dynamic-ones#s-altering-existing-routes">altering existing routes</a>. Specifically, for a view page, the route is of the form <code class="language-plaintext highlighter-rouge">view.view_id.page_id</code>. So you would have something like the following:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="k">protected</span> <span class="k">function</span> <span class="n">alterRoutes</span><span class="p">(</span><span class="kt">RouteCollection</span> <span class="nv">$collection</span><span class="p">)</span> <span class="p">{</span>

    <span class="c1">// Disable Big Pipe for my view.</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$route</span> <span class="o">=</span> <span class="nv">$collection</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'view.view_id.page_id'</span><span class="p">))</span> <span class="p">{</span>
      <span class="nv">$route</span><span class="o">-&gt;</span><span class="nf">setOption</span><span class="p">(</span><span class="s1">'_no_big_pipe'</span><span class="p">,</span> <span class="kc">TRUE</span><span class="p">);</span>
    <span class="p">}</span>

  <span class="p">}</span>
</code></pre></div></div>

<p>But in my case, the view is a block that’s embedded in a node page. I cannot simply alter the route <code class="language-plaintext highlighter-rouge">entity.node.canonical</code>, because this would disable it on 99% of the site!!</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/disable-bigpipe.jpg">
      <img src="https://blog.karimratib.me/assets/disable-bigpipe.jpg" style="max-width: 100%;" alt="What do you mean, my memes are obsolete??" />
    </a>
    <figcaption>What do you mean, my memes are obsolete??</figcaption>
  </figure>
</div>

<h1 id="solution-2b-disable-big-pipe-for-a-specific-url">Solution 2b: Disable Big Pipe for a specific URL</h1>
<p>I turned to good old <a href="https://drupal.stackexchange.com/q/320680/767">Stack Overflow (technically, Drupal Answers)</a> to query the hive-mind. Thanks to the ever-helpful and super-knowledgeable <strong>4uk4</strong> for his suggestion! Although I ended up taking a different approach, I will remember that I can override parameterized routes with specific ones because this will surely come in handy in the future.</p>

<p>The approach I ended up following is based on Wim Leer’s <a href="https://git.drupalcode.org/project/big_pipe_demo">Big Pipe Strategy demo</a>, where he catches every request in real-time and decides whether to return the Big Pipe placeholders or to ignore them. In my case, instead of examining the request’s query arguments for a specific “disable” signal, I compare the URI itself with the target page’s URL:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="k">public</span> <span class="k">function</span> <span class="n">processPlaceholders</span><span class="p">(</span><span class="kt">array</span> <span class="nv">$placeholders</span><span class="p">)</span> <span class="p">{</span>

    <span class="c1">// Ignore Big Pipe for my page URL.</span>
    <span class="nv">$current_uri</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">request</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getRequestUri</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$current_uri</span><span class="p">,</span> <span class="s1">'/path/to/page-to-ignore'</span><span class="p">))</span> <span class="p">{</span>
      <span class="k">return</span> <span class="p">[];</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">bigPipeStrategy</span><span class="o">-&gt;</span><span class="nf">processPlaceholders</span><span class="p">(</span><span class="nv">$placeholders</span><span class="p">);</span>
  <span class="p">}</span>
</code></pre></div></div>
<p>Et voilà ! Another bug bites the dust.</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[Big Pipe on Drupal 9+ breaks form redirects. In this post, I explain how I fixed it for a specific but common case.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/disable-bigpipe.jpg" /><media:content medium="image" url="https://blog.karimratib.me/assets/disable-bigpipe.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: From cookies to user sessions</title><link href="https://blog.karimratib.me/2024/08/20/drupal-sessions.html" rel="alternate" type="text/html" title="Drupal 10: From cookies to user sessions" /><published>2024-08-20T00:00:00+00:00</published><updated>2024-08-20T00:00:00+00:00</updated><id>https://blog.karimratib.me/2024/08/20/drupal-sessions</id><content type="html" xml:base="https://blog.karimratib.me/2024/08/20/drupal-sessions.html"><![CDATA[<p>When you need to examine user session tokens, you know you’re deep in the bowels of the CMS. That’s what happened to me recently, as I was debugging why CloudFlare was mixing up user sessions and giving admin access to otherwise unpermissioned users :scream:</p>

<p>To help debug this, I needed a way to associate user cookies with entries from the <code class="language-plaintext highlighter-rouge">sessions</code> table. I wrote a drush script to do exactly that: Given the value of the SESSXXX cookie in your browser, the script will find the corresponding <code class="language-plaintext highlighter-rouge">sessions</code> entry and dump its information, decoding the session metadata in the process:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>drush scr export_sessions.php <span class="nt">--</span> <span class="nt">--cookie</span><span class="o">=</span>5XvW3NGG8q1PcCrEXn676THvQBitaUwDiPw8XzAgXtihV43u
</code></pre></div></div>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
        </span><span class="nl">"uid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"sid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-Xcm0ar3mWcMhIhhBANA3K-jUx3JNOsu190LPEUzIN8"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"hostname"</span><span class="p">:</span><span class="w"> </span><span class="s2">"172.24.0.1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"timestamp"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1724179990"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"session"</span><span class="p">:</span><span class="w"> </span><span class="s2">"_sf2_attributes|a:1:{s:3:</span><span class="se">\"</span><span class="s2">uid</span><span class="se">\"</span><span class="s2">;s:1:</span><span class="se">\"</span><span class="s2">1</span><span class="se">\"</span><span class="s2">;}_sf2_meta|a:4:{s:1:</span><span class="se">\"</span><span class="s2">u</span><span class="se">\"</span><span class="s2">;i:1724179990;s:1:</span><span class="se">\"</span><span class="s2">c</span><span class="se">\"</span><span class="s2">;i:1723574737;s:1:</span><span class="se">\"</span><span class="s2">l</span><span class="se">\"</span><span class="s2">;i:2000000;s:1:</span><span class="se">\"</span><span class="s2">s</span><span class="se">\"</span><span class="s2">;s:43:</span><span class="se">\"</span><span class="s2">OCpNT7IvSsWNfPeYXam7E7XFPTKqb-8qWPUTMe8MFlQ</span><span class="se">\"</span><span class="s2">;}"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"sf2"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="p">{</span><span class="w">
                </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"attributes"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"uid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1"</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="p">{</span><span class="w">
                </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"meta"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"u"</span><span class="p">:</span><span class="w"> </span><span class="mi">1724179990</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"c"</span><span class="p">:</span><span class="w"> </span><span class="mi">1723574737</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"l"</span><span class="p">:</span><span class="w"> </span><span class="mi">2000000</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"s"</span><span class="p">:</span><span class="w"> </span><span class="s2">"OCpNT7IvSsWNfPeYXam7E7XFPTKqb-8qWPUTMe8MFlQ"</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>The same script will dump ALL sessions if you don’t pass in a cookie value. Here’s the source code of <code class="language-plaintext highlighter-rouge">export_sessions.php</code>:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>

<span class="cd">/**
 * Retrieve session entry for given cookie.
 * Based on https://drupal.stackexchange.com/a/231726/767
 */</span>

<span class="kn">use</span> <span class="nc">Drupal\Component\Utility\Crypt</span><span class="p">;</span>

<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$extra</span><span class="p">))</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$extra</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="s1">'--cookie='</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">die</span><span class="p">(</span><span class="s2">"Usage: drush scr export_sessions.php [-- --cookie=&lt;value of SESSxxxx cookie&gt;]</span><span class="se">\n</span><span class="s2">"</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">else</span> <span class="p">{</span>
    <span class="nv">$cookie</span> <span class="o">=</span> <span class="nb">trim</span><span class="p">(</span><span class="nb">str_replace</span><span class="p">(</span><span class="s1">'--cookie='</span><span class="p">,</span> <span class="s1">''</span><span class="p">,</span> <span class="nv">$extra</span><span class="p">[</span><span class="mi">0</span><span class="p">]));</span>
    <span class="nv">$cookie</span> <span class="o">=</span> <span class="nb">urldecode</span><span class="p">(</span><span class="nv">$cookie</span><span class="p">);</span>
    <span class="nv">$sid</span> <span class="o">=</span> <span class="nc">Crypt</span><span class="o">::</span><span class="nf">hashBase64</span><span class="p">(</span><span class="nv">$cookie</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="nv">$connection</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">database</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(</span><span class="k">isset</span><span class="p">(</span><span class="nv">$sid</span><span class="p">))</span> <span class="p">{</span>
  <span class="nv">$query</span> <span class="o">=</span> <span class="nv">$connection</span><span class="o">-&gt;</span><span class="nf">query</span><span class="p">(</span><span class="s1">'SELECT * FROM {sessions} WHERE sid = :sid'</span><span class="p">,</span> <span class="p">[</span><span class="s1">':sid'</span> <span class="o">=&gt;</span> <span class="nv">$sid</span><span class="p">]);</span>
<span class="p">}</span>
<span class="k">else</span> <span class="p">{</span>
  <span class="nv">$query</span> <span class="o">=</span> <span class="nv">$connection</span><span class="o">-&gt;</span><span class="nf">query</span><span class="p">(</span><span class="s1">'SELECT * FROM {sessions}'</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">echo</span> <span class="nb">json_encode</span><span class="p">(</span><span class="nb">array_map</span><span class="p">(</span><span class="k">function</span><span class="p">(</span><span class="nv">$session</span><span class="p">)</span> <span class="p">{</span>
  <span class="nb">preg_match_all</span><span class="p">(</span><span class="s1">'/_sf2_(\w+)\|/'</span><span class="p">,</span> <span class="nv">$session</span><span class="o">-&gt;</span><span class="n">session</span><span class="p">,</span> <span class="nv">$matches</span><span class="p">,</span> <span class="no">PREG_OFFSET_CAPTURE</span> <span class="o">|</span> <span class="no">PREG_SET_ORDER</span><span class="p">);</span>
  <span class="nv">$session</span><span class="o">-&gt;</span><span class="n">sf2</span> <span class="o">=</span> <span class="nb">array_map</span><span class="p">(</span><span class="k">function</span><span class="p">(</span><span class="nv">$match</span><span class="p">,</span> <span class="nv">$index</span><span class="p">)</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$session</span><span class="p">,</span> <span class="nv">$matches</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$offset</span> <span class="o">=</span> <span class="nv">$match</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">1</span><span class="p">]</span> <span class="o">+</span> <span class="nb">strlen</span><span class="p">(</span><span class="nv">$match</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">0</span><span class="p">]);</span>
    <span class="nv">$length</span> <span class="o">=</span> <span class="nv">$index</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="nb">count</span><span class="p">(</span><span class="nv">$matches</span><span class="p">)</span> <span class="o">?</span>
      <span class="nv">$matches</span><span class="p">[</span><span class="nv">$index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">][</span><span class="mi">0</span><span class="p">][</span><span class="mi">1</span><span class="p">]</span> <span class="o">-</span> <span class="nv">$offset</span><span class="o">:</span>
      <span class="nb">strlen</span><span class="p">(</span><span class="nv">$session</span><span class="o">-&gt;</span><span class="n">session</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">-</span> <span class="nv">$match</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">1</span><span class="p">];</span>
    <span class="k">return</span> <span class="p">[</span>
      <span class="s1">'name'</span> <span class="o">=&gt;</span> <span class="nv">$match</span><span class="p">[</span><span class="mi">1</span><span class="p">][</span><span class="mi">0</span><span class="p">],</span>
      <span class="s1">'value'</span> <span class="o">=&gt;</span> <span class="nb">unserialize</span><span class="p">(</span><span class="nb">substr</span><span class="p">(</span><span class="nv">$session</span><span class="o">-&gt;</span><span class="n">session</span><span class="p">,</span> <span class="nv">$offset</span><span class="p">,</span> <span class="nv">$length</span><span class="p">)),</span>
    <span class="p">];</span>
  <span class="p">},</span> <span class="nv">$matches</span><span class="p">,</span> <span class="nb">array_keys</span><span class="p">(</span><span class="nv">$matches</span><span class="p">));</span>
  <span class="k">return</span> <span class="nv">$session</span><span class="p">;</span>
<span class="p">},</span> <span class="nv">$query</span><span class="o">-&gt;</span><span class="nf">fetchAll</span><span class="p">()),</span> <span class="no">JSON_PRETTY_PRINT</span><span class="p">)</span> <span class="mf">.</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">;</span>
</code></pre></div></div>
<p>That’s it. Short and sweet! :candy:</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In this post, I show a script that relates user cookies to Drupal session information.]]></summary></entry><entry><title type="html">Drupal 9: Troubleshooting Cache API issues, Part 1: Xdebug, wodby/drupal, VS Code</title><link href="https://blog.karimratib.me/2023/10/25/xdebug.html" rel="alternate" type="text/html" title="Drupal 9: Troubleshooting Cache API issues, Part 1: Xdebug, wodby/drupal, VS Code" /><published>2023-10-25T00:00:00+00:00</published><updated>2023-10-25T00:00:00+00:00</updated><id>https://blog.karimratib.me/2023/10/25/xdebug</id><content type="html" xml:base="https://blog.karimratib.me/2023/10/25/xdebug.html"><![CDATA[<p>In my 30+ years of programming, my go-to debugging tool has been the judicious usage of <code class="language-plaintext highlighter-rouge">print</code> commands on the appropriate variables at the appropriate times. Of course, <code class="language-plaintext highlighter-rouge">print</code> takes many different forms depending on the technology stack and the application model, but the principle remains the same. In very few cases did this approach fail me, and I stumbled across one such case as I was debugging the notoriously tricky Drupal <a href="https://www.drupal.org/docs/8/api/cache-api/cache-api">Cache API</a>. In a nutshell, there was one module, among the dozens of core, contrib and custom modules making up that particular site, that was invalidating the static page cache and preventing pages from being cached. I wanted to find which module was the culprit.</p>

<p>The problem with this issue is that the Cache API is called thousands of times per request - for pretty much every theming function participating in a page render. Further, the caching logic is complex as it involves combinations of cache tags, <code class="language-plaintext highlighter-rouge">max-age</code> settings, and various other mechanisms that affect the decisions of which caching tables to use and which caching headers to return in the HTTP response.</p>

<p>Trying to pinpoint the particular condition that caused the cache invalidation in this case using <code class="language-plaintext highlighter-rouge">print</code> statements would have been an inefficient and tedious process, and the client wouldn’t have liked to pay for that inefficiency. Kind of like the game of 20 questions, but with incomplete information and many, many decision branches. So I decided to bite the bullet and set up my Xdebug environment to catch the bug red-handed, so to speak. With its pants down, so to speak. To catch it in the act, so to speak.</p>

<p>Here’s a high level diagram of the various components at play here. I slightly modified it from the original at <a href="https://blog.devsense.com/2019/debugging-php-on-docker-with-visual-studio-code">this other tutorial on the same topic</a>.</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/xdebug.png">
      <img src="https://blog.karimratib.me/assets/xdebug.png" style="max-width: 100%;" alt="Xdebug within php-fpm container communicates with VS Code IDE on host via port 9003." />
    </a>
    <figcaption>Xdebug within php-fpm container communicates with VS Code IDE on host via port 9003.</figcaption>
  </figure>
</div>

<p>My development environment is made up of the excellent <a href="https://github.com/wodby/docker4drupal">Docker-based Drupal stack</a> by Wodby. I can’t say enough good things about this framework, which has allowed me to start new Drupal projects, and even adopt legacy ones, on a solid footing without breaking a sweat. The architecture is simple, documentation is clear, customization is easy. I’ve been able to share development environments with team members using macOS and Windows systems with minimal changes.</p>

<p>The <a href="https://github.com/wodby/drupal-php">wodby/drupal-php</a> image comes loaded with the Xdebug extension, and it’s “only” necessary to configure the right environment variables to activate it. I say “only” because many of the settings are non-obvious and required some experimentation before I could get them running, in addition to a VS Code configuration to match.</p>

<p>Here’s my current setup, in the main <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> file running the full Drupal stack:</p>
<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">php</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">wodby/drupal-php:$PHP_TAG</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">PHP_EXTENSIONS_DISABLE</span><span class="pi">:</span> <span class="s1">'</span><span class="s">'</span> <span class="c1"># or any value that does NOT include xdebug</span>
      <span class="na">PHP_XDEBUG</span><span class="pi">:</span> <span class="m">1</span>
      <span class="na">PHP_XDEBUG_MODE</span><span class="pi">:</span> <span class="s">debug</span>
      <span class="na">PHP_XDEBUG_START_WITH_REQUEST</span><span class="pi">:</span> <span class="s">yes</span>
      <span class="na">PHP_XDEBUG_CLIENT_HOST</span><span class="pi">:</span> <span class="s">host.docker.internal</span>
      <span class="na">PHP_XDEBUG_LOG</span><span class="pi">:</span> <span class="s">/tmp/php-xdebug.log</span>
    <span class="na">extra_hosts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">host.docker.internal:host-gateway"</span>
</code></pre></div></div>
<p>Here’s what the non-obvious settings mean:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">PHP_EXTENSIONS_DISABLE: ''</code> prevents the PHP container from disabling the <code class="language-plaintext highlighter-rouge">xdebug</code> extension - which for some reason is the default in <a href="https://github.com/wodby/php?tab=readme-ov-file#php-extensions"><code class="language-plaintext highlighter-rouge">wodby/php</code></a>.</li>
  <li><code class="language-plaintext highlighter-rouge">PHP_XDEBUG_MODE: debug</code> enables <a href="https://xdebug.org/docs/step_debug#configure">Xdebug step debugging</a>, which is our purpose here.</li>
  <li><code class="language-plaintext highlighter-rouge">PHP_XDEBUG_START_WITH_REQUEST: yes</code> means that Xdebug is activated at every request, automatically.</li>
  <li><code class="language-plaintext highlighter-rouge">PHP_XDEBUG_CLIENT_HOST: host.docker.internal</code> is the all-important address of the machine running the debugging client - in my case, VS Code on my local machine. <a href="https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host">According to documentation</a>, the name <code class="language-plaintext highlighter-rouge">host.docker.internal</code> is automatically available in Docker 18.03+ Mac/Win, <strong>but not on Linux</strong>. For Linux, we add the stanza <code class="language-plaintext highlighter-rouge">extra_hosts: "host.docker.internal:host-gateway"</code> which maps that domain name to Docker’s gateway IP, which is the Docker host, which is my laptop OS running VS Code :sweat_smile:</li>
</ul>

<p>But that’s only half of the story. The other half is convincing VS Code to act as a debugging client to Xdebug. To do that, we use the <a href="https://marketplace.visualstudio.com/items?itemName=xdebug.php-debug">PHP Debug VS Code extension</a> and we <a href="https://code.visualstudio.com/docs/editor/debugging#_launch-configurations">customize the Launch configurations</a> to add the Xdebug endpoint. Basically, we create a <code class="language-plaintext highlighter-rouge">.vscode/launch.json</code> file in the project root with the following content:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.2.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"configurations"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
   </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Listen for Xdebug"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"php"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"request"</span><span class="p">:</span><span class="w"> </span><span class="s2">"launch"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"port"</span><span class="p">:</span><span class="w"> </span><span class="mi">9003</span><span class="p">,</span><span class="w">
      </span><span class="nl">"pathMappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"/var/www/html/"</span><span class="p">:</span><span class="w"> </span><span class="s2">"${workspaceFolder}/src"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>Here’s what the non-obvious settings mean:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">"port": 9003</code> is the default port that Xdebug hits on the client, and that’s where VS Code should be listening for debug events.</li>
  <li><code class="language-plaintext highlighter-rouge">"pathMappings": { "/var/www/html/": "${workspaceFolder}/src" }</code> maps the Docker filesystem path <code class="language-plaintext highlighter-rouge">/var/www/html</code> where the app resides to the actual host path <code class="language-plaintext highlighter-rouge">"${workspaceFolder}/src"</code> where <code class="language-plaintext highlighter-rouge">${workspaceFolder}</code> is a <a href="https://code.visualstudio.com/docs/editor/variables-reference">VS Code variable</a>.</li>
</ul>

<p>With these in place, it should be now possible to place a breakpoint in, say, <code class="language-plaintext highlighter-rouge">src/web/index.php</code> (the Drupal main entrypoint) and catch every request! Select <strong>Run &gt; Start Debugging</strong> or or click the <strong>Listen for Xdebug</strong> configuration in the bottom status bar. We are finally ready to start debugging the Drupal Cache API :ghost:</p>

<h2 id="troubleshooting">Troubleshooting</h2>
<p>Of course, this setup didn’t come by without many failures and much head-scratching, perhaps even some teeth-clenching. If your 100% guaranteed breakpoint (like one in <code class="language-plaintext highlighter-rouge">src/web/index.php</code>) is not being hit, then it’s time to put on your sleuthing hat :detective:</p>

<p>Check that the Xdebug log is active and connected. Running <code class="language-plaintext highlighter-rouge">docker-compose exec php tail -f /tmp/php-xdebug.log</code> should show messages like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[112] Log opened at 2023-10-25 06:42:12.885888
[112] [Step Debug] INFO: Connecting to configured address/port: host.docker.internal:9003.
[112] [Step Debug] INFO: Connected to debugging client: host.docker.internal:9003 (through xdebug.client_host/xdebug.client_port). :-)
</code></pre></div></div>
<p>Yes, that final smiley is part of the log :-)</p>

<p>If instead, you see a message like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tail: can't open '/tmp/php-xdebug.log': No such file or directory
tail: no files
</code></pre></div></div>
<p>Then the Xdebug extension is not active, which could mean <code class="language-plaintext highlighter-rouge">PHP_EXTENSIONS_DISABLE</code> is still set to include <code class="language-plaintext highlighter-rouge">xdebug</code>.</p>

<p>If you see a sad smiley message like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[112] [Step Debug] ERR: Could not connect to debugging client. Tried: host.docker.internal:9003 (through xdebug.client_host/xdebug.client_port) :-(
</code></pre></div></div>
<p>Then check that a connection can be established between Xdebug and VS Code. Running <code class="language-plaintext highlighter-rouge">docker-compose exec php nc -zv host.docker.internal 9003</code> should return a successful response like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>host.docker.internal (172.17.0.1:9003) open
</code></pre></div></div>
<p>Anything else is a sign that the Docker container is unable to connect to the host on port 9003. Check your <code class="language-plaintext highlighter-rouge">host.docker.internal</code> name resolution, check the <code class="language-plaintext highlighter-rouge">launch.json</code> port setting, turn it off and on again, talk to your rubber duck - you know the drill!</p>

<h2 id="appendix-annoying-drush-warnings">Appendix: Annoying drush warnings</h2>
<p>With Xdebug activated, you may be bombarded with multiple lines of warnings when running <code class="language-plaintext highlighter-rouge">drush</code> commands, especially when you are not debugging on the IDE side:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[20-Aug-2024 19:10:28 UTC] Xdebug: [Log Files] File '/tmp/php-xdebug.log' could not be opened.
[20-Aug-2024 19:10:28 UTC] Xdebug: [Step Debug] Could not connect to debugging client. Tried: host.docker.internal:9003 (through xdebug.client_host/xdebug.client_port).
</code></pre></div></div>
<p>In this case, you can run <code class="language-plaintext highlighter-rouge">export XDEBUG_MODE=off</code> in the <code class="language-plaintext highlighter-rouge">bash</code> session where you’re running <code class="language-plaintext highlighter-rouge">drush</code>, thereby deactivating Xdebug in the session, and saving a few bits from your eyes :sob:</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In this post, I explain how to configure Xdebug with VS Code in the context of deep Drupal debugging.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/xdebug.png" /><media:content medium="image" url="https://blog.karimratib.me/assets/xdebug.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 9: Backup and Migrate - Drush 11 support</title><link href="https://blog.karimratib.me/2023/06/01/backup-migrate-drush.html" rel="alternate" type="text/html" title="Drupal 9: Backup and Migrate - Drush 11 support" /><published>2023-06-01T00:00:00+00:00</published><updated>2023-06-01T00:00:00+00:00</updated><id>https://blog.karimratib.me/2023/06/01/backup-migrate-drush</id><content type="html" xml:base="https://blog.karimratib.me/2023/06/01/backup-migrate-drush.html"><![CDATA[<p>Supporting content migrations across stages is a tricky subject, and most tools I reviewed seemed too fragile or too complex to be delivered to a client. We opted to use a simple workflow based on <a href="https://www.drupal.org/project/backup_migrate">BAM (Backup and Migrate)</a> coupled with config re-synchronization. To help automate the process, I wrote a set of <code class="language-plaintext highlighter-rouge">drush</code> commands that implement BAM backup and restore. It’s been tested extensively, but only with a specific set of sources and destinations, so I am reproducing the current code here until it gets published as a module. One design decision I made was to produce output as JSON, to make it easier for downstream automation.</p>

<p>The typical usage scenario is the following:</p>
<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>drush bamb default_db private_files
// <span class="o">=&gt;</span> <span class="o">{</span>
//    <span class="s2">"status"</span>: <span class="s2">"success"</span>,
//    <span class="s2">"message"</span>: <span class="s2">"Backup complete."</span>
//<span class="o">}</span>
<span class="nv">$ </span>drush bamls <span class="nt">--files</span><span class="o">=</span>private_files
// <span class="o">=&gt;</span> <span class="o">{</span>
//    <span class="s2">"sources"</span>: <span class="o">[</span>
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"default_db"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Default Drupal Database"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"DefaultDB"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"entire_site"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Entire Site (do not use)"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"EntireSite"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"private_files"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Private Files Directory"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"DrupalFiles"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"public_files"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Public Files Directory"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"DrupalFiles"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"ssot_database"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"SSoT Database"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"PostgreSQL"</span>
//        <span class="o">}</span>
//    <span class="o">]</span>,
//    <span class="s2">"destinations"</span>: <span class="o">[</span>
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"private_files"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Private Files Directory"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"Directory"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"s3_bucket"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"S3 Bucket"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"awss3"</span>
//        <span class="o">}</span>
//    <span class="o">]</span>,
//    <span class="s2">"files"</span>: <span class="o">{</span>
//        <span class="s2">"private_files"</span>: <span class="o">[</span>
//            <span class="o">{</span>
//                <span class="s2">"id"</span>: <span class="s2">"backup-2023-01-27T15-44-19.sql.gz"</span>,
//                <span class="s2">"filename"</span>: <span class="s2">"prod-2023-01-27T15-44-19.sql.gz"</span>,
//                <span class="s2">"filesize"</span>: 19499222,
//                <span class="s2">"datestamp"</span>: 1674869134
//            <span class="o">}</span>
//        <span class="o">]</span>
//    <span class="o">}</span>
//<span class="o">}</span>
<span class="nv">$ </span>drush bamr default_db private_files backup-2023-01-27T15-44-19.sql.gz
// <span class="o">=&gt;</span> <span class="o">{</span>
//    <span class="s2">"status"</span>: <span class="s2">"success"</span>,
//    <span class="s2">"message"</span>: <span class="s2">"Restore complete."</span>
//<span class="o">}</span>
</code></pre></div></div>

<p>And here’s the source for the command:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>

<span class="kn">namespace</span> <span class="nn">Drush\Commands</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">Drush\Drush</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Drush\Commands\DrushCommands</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Drush\Boot\DrupalBootLevels</span><span class="p">;</span>
<span class="kn">use</span> <span class="nf">Drupal\backup_migrate</span><span class="nc">\Core\Destination\ListableDestinationInterface</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Symfony\Component\Console\Input\InputOption</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">BackupMigrateCommands</span> <span class="kd">extends</span> <span class="nc">DrushCommands</span>
<span class="p">{</span>
    <span class="cd">/**
     * List sources and destinations.
     *
     * @command backup_migrate:list
     * @aliases bamls
     *
     * @option sources Flag to list sources (default: yes, use --no-sources to hide)
     * @option destinations Flag to list destinations (default: yes, use --no-destinations to hide)
     * @option files Flag to list files for a comma-separated list of destination identifiers (default: none)
     *
     * @param options
     *
     * @return string JSON listing of sources, destinations, files
     *
     */</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">list</span><span class="p">(</span><span class="kt">array</span> <span class="nv">$options</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s1">'sources'</span> <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">,</span>
        <span class="s1">'destinations'</span> <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">,</span>
        <span class="s1">'files'</span> <span class="o">=&gt;</span> <span class="nc">InputOption</span><span class="o">::</span><span class="no">VALUE_REQUIRED</span><span class="p">,</span>
    <span class="p">])</span><span class="o">:</span> <span class="n">string</span> <span class="p">{</span>
        <span class="nc">Drush</span><span class="o">::</span><span class="nf">bootstrapManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">doBootstrap</span><span class="p">(</span><span class="nc">DrupalBootLevels</span><span class="o">::</span><span class="no">FULL</span><span class="p">);</span>
        <span class="nv">$bam</span> <span class="o">=</span> <span class="nf">\backup_migrate_get_service_object</span><span class="p">();</span>
        <span class="nv">$output</span> <span class="o">=</span> <span class="p">[];</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$options</span><span class="p">[</span><span class="s1">'sources'</span><span class="p">])</span> <span class="p">{</span>
            <span class="nv">$output</span><span class="p">[</span><span class="s1">'sources'</span><span class="p">]</span> <span class="o">=</span> <span class="nb">array_reduce</span><span class="p">(</span><span class="nb">array_keys</span><span class="p">(</span><span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">sources</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getAll</span><span class="p">()),</span> <span class="k">function</span><span class="p">(</span><span class="nv">$sources</span><span class="p">,</span> <span class="nv">$source_id</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$source</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">entityTypeManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getStorage</span><span class="p">(</span><span class="s1">'backup_migrate_source'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">load</span><span class="p">(</span><span class="nv">$source_id</span><span class="p">);</span>
                <span class="k">if</span> <span class="p">(</span><span class="nv">$source</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$sources</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span>
                        <span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$source_id</span><span class="p">,</span>
                        <span class="s1">'label'</span> <span class="o">=&gt;</span> <span class="nv">$source</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'label'</span><span class="p">),</span>
                        <span class="s1">'type'</span> <span class="o">=&gt;</span> <span class="nv">$source</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'type'</span><span class="p">),</span>
                    <span class="p">];</span>
                <span class="p">}</span>
                <span class="k">return</span> <span class="nv">$sources</span><span class="p">;</span>
            <span class="p">},</span> <span class="p">[]);</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$options</span><span class="p">[</span><span class="s1">'destinations'</span><span class="p">])</span> <span class="p">{</span>
            <span class="nv">$output</span><span class="p">[</span><span class="s1">'destinations'</span><span class="p">]</span> <span class="o">=</span> <span class="nb">array_reduce</span><span class="p">(</span><span class="nb">array_keys</span><span class="p">(</span><span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">destinations</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getAll</span><span class="p">()),</span> <span class="k">function</span><span class="p">(</span><span class="nv">$destinations</span><span class="p">,</span> <span class="nv">$destination_id</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$destination</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">entityTypeManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getStorage</span><span class="p">(</span><span class="s1">'backup_migrate_destination'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">load</span><span class="p">(</span><span class="nv">$destination_id</span><span class="p">);</span>
                <span class="k">if</span> <span class="p">(</span><span class="nv">$destination</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$destinations</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span>
                        <span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$destination_id</span><span class="p">,</span>
                        <span class="s1">'label'</span> <span class="o">=&gt;</span> <span class="nv">$destination</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'label'</span><span class="p">),</span>
                        <span class="s1">'type'</span> <span class="o">=&gt;</span> <span class="nv">$destination</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'type'</span><span class="p">),</span>
                    <span class="p">];</span>
                <span class="p">}</span>
                <span class="k">return</span> <span class="nv">$destinations</span><span class="p">;</span>
            <span class="p">},</span> <span class="p">[]);</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$options</span><span class="p">[</span><span class="s1">'files'</span><span class="p">])</span> <span class="p">{</span>
            <span class="k">foreach</span><span class="p">(</span><span class="nb">array_map</span><span class="p">(</span><span class="s1">'trim'</span><span class="p">,</span> <span class="nb">explode</span><span class="p">(</span><span class="s1">','</span><span class="p">,</span> <span class="nv">$options</span><span class="p">[</span><span class="s1">'files'</span><span class="p">]))</span> <span class="k">as</span> <span class="nv">$destination_id</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$destination</span> <span class="o">=</span> <span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">destinations</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="nv">$destination_id</span><span class="p">);</span>
                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nv">$destination</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">logger</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">warning</span><span class="p">(</span><span class="nf">dt</span><span class="p">(</span><span class="s1">'The destination !id does not exist.'</span><span class="p">,</span> <span class="p">[</span><span class="s1">'!id'</span> <span class="o">=&gt;</span> <span class="nv">$destination_id</span><span class="p">]));</span>
                    <span class="k">continue</span><span class="p">;</span>
                <span class="p">}</span>
                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nv">$destination</span> <span class="k">instanceof</span> <span class="nc">ListableDestinationInterface</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">logger</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">warning</span><span class="p">(</span><span class="nf">dt</span><span class="p">(</span><span class="s1">'The destination !id is not listable.'</span><span class="p">,</span> <span class="p">[</span><span class="s1">'!id'</span> <span class="o">=&gt;</span> <span class="nv">$destination_id</span><span class="p">]));</span>
                    <span class="k">continue</span><span class="p">;</span>
                <span class="p">}</span>
                <span class="k">try</span> <span class="p">{</span>
                    <span class="nv">$files</span> <span class="o">=</span> <span class="nv">$destination</span><span class="o">-&gt;</span><span class="nf">listFiles</span><span class="p">();</span>
                    <span class="nv">$output</span><span class="p">[</span><span class="s1">'files'</span><span class="p">][</span><span class="nv">$destination_id</span><span class="p">]</span> <span class="o">=</span> <span class="nb">array_reduce</span><span class="p">(</span><span class="nb">array_keys</span><span class="p">(</span><span class="nv">$files</span><span class="p">),</span> <span class="k">function</span><span class="p">(</span><span class="nv">$files_info</span><span class="p">,</span> <span class="nv">$file_id</span><span class="p">)</span> <span class="k">use</span><span class="p">(</span><span class="nv">$files</span><span class="p">)</span> <span class="p">{</span>
                        <span class="nv">$files_info</span><span class="p">[]</span> <span class="o">=</span> <span class="nb">array_merge</span><span class="p">([</span>
                            <span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$file_id</span><span class="p">,</span>
                            <span class="s1">'filename'</span> <span class="o">=&gt;</span> <span class="nv">$files</span><span class="p">[</span><span class="nv">$file_id</span><span class="p">]</span><span class="o">-&gt;</span><span class="nf">getFullName</span><span class="p">(),</span>
                        <span class="p">],</span> <span class="nv">$files</span><span class="p">[</span><span class="nv">$file_id</span><span class="p">]</span><span class="o">-&gt;</span><span class="nf">getMetaAll</span><span class="p">());</span>
                        <span class="k">return</span> <span class="nv">$files_info</span><span class="p">;</span>
                    <span class="p">},</span> <span class="p">[]);</span>
                    <span class="nb">usort</span><span class="p">(</span><span class="nv">$output</span><span class="p">[</span><span class="s1">'files'</span><span class="p">][</span><span class="nv">$destination_id</span><span class="p">],</span> <span class="k">function</span><span class="p">(</span><span class="nv">$file1</span><span class="p">,</span> <span class="nv">$file2</span><span class="p">)</span> <span class="p">{</span>
                        <span class="c1">// TODO What if datestamp is not available?</span>
                        <span class="nv">$a</span> <span class="o">=</span> <span class="nv">$file1</span><span class="p">[</span><span class="s1">'datestamp'</span><span class="p">];</span>
                        <span class="nv">$b</span> <span class="o">=</span> <span class="nv">$file2</span><span class="p">[</span><span class="s1">'datestamp'</span><span class="p">];</span>
                        <span class="k">if</span> <span class="p">(</span><span class="nv">$a</span> <span class="o">==</span> <span class="nv">$b</span><span class="p">)</span> <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
                        <span class="k">return</span> <span class="p">(</span><span class="nv">$a</span> <span class="o">&lt;</span> <span class="nv">$b</span><span class="p">)</span> <span class="o">?</span> <span class="o">-</span><span class="mi">1</span> <span class="o">:</span> <span class="mi">1</span><span class="p">;</span>
                    <span class="p">});</span>
                <span class="p">}</span>
                <span class="k">catch</span> <span class="p">(</span><span class="nc">\Exception</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">logger</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">error</span><span class="p">(</span><span class="nf">dt</span><span class="p">(</span><span class="s1">'The destination !id caused an error: !error'</span><span class="p">,</span> <span class="p">[</span>
                        <span class="s1">'!id'</span> <span class="o">=&gt;</span> <span class="nv">$destination_id</span><span class="p">,</span>
                        <span class="s1">'!error'</span> <span class="o">=&gt;</span> <span class="nv">$e</span><span class="o">-&gt;</span><span class="nf">getMessage</span><span class="p">()</span>
                    <span class="p">]));</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="nb">json_encode</span><span class="p">(</span><span class="nv">$output</span><span class="p">,</span> <span class="no">JSON_PRETTY_PRINT</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="cd">/**
     * Backup.
     *
     * @command backup_migrate:backup
     * @aliases bamb
     *
     * @param source_id Identifier of the Backup Source.
     * @param destination_id Identifier of the Backup Destination.
     *
     * @return string Backup completion status
     *
     * @throws \Drupal\backup_migrate\Core\Exception\BackupMigrateException
     *
     */</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">backup</span><span class="p">(</span>
        <span class="nv">$source_id</span><span class="p">,</span>
        <span class="nv">$destination_id</span>
    <span class="p">):</span> <span class="kt">string</span>
    <span class="p">{</span>
        <span class="nc">Drush</span><span class="o">::</span><span class="nf">bootstrapManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">doBootstrap</span><span class="p">(</span><span class="nc">DrupalBootLevels</span><span class="o">::</span><span class="no">FULL</span><span class="p">);</span>
        <span class="nv">$bam</span> <span class="o">=</span> <span class="nf">\backup_migrate_get_service_object</span><span class="p">();</span>
        <span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">backup</span><span class="p">(</span><span class="nv">$source_id</span><span class="p">,</span> <span class="nv">$destination_id</span><span class="p">);</span>
        <span class="k">return</span> <span class="nb">json_encode</span><span class="p">([</span>
            <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'success'</span><span class="p">,</span>
            <span class="s1">'message'</span> <span class="o">=&gt;</span> <span class="nf">dt</span><span class="p">(</span><span class="s1">'Backup complete.'</span><span class="p">)</span>
        <span class="p">],</span> <span class="no">JSON_PRETTY_PRINT</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="cd">/**
     * Restore.
     *
     * @command backup_migrate:restore
     * @aliases bamr
     *
     * @param source_id Identifier of the Backup Source.
     * @param destination_id Identifier of the Backup Destination.
     * @param file_id optional Identifier of the Destination file.
     *
     * @return string Restore completion status
     *
     * @throws \Drupal\backup_migrate\Core\Exception\BackupMigrateException
     *
     */</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">restore</span><span class="p">(</span>
        <span class="nv">$source_id</span><span class="p">,</span>
        <span class="nv">$destination_id</span><span class="p">,</span>
        <span class="nv">$file_id</span> <span class="o">=</span> <span class="kc">null</span><span class="p">,</span>
    <span class="p">):</span> <span class="kt">string</span>
    <span class="p">{</span>
        <span class="nc">Drush</span><span class="o">::</span><span class="nf">bootstrapManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">doBootstrap</span><span class="p">(</span><span class="nc">DrupalBootLevels</span><span class="o">::</span><span class="no">FULL</span><span class="p">);</span>
        <span class="nv">$bam</span> <span class="o">=</span> <span class="nf">\backup_migrate_get_service_object</span><span class="p">();</span>
        <span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">restore</span><span class="p">(</span><span class="nv">$source_id</span><span class="p">,</span> <span class="nv">$destination_id</span><span class="p">,</span> <span class="nv">$file_id</span><span class="p">);</span>
        <span class="k">return</span> <span class="nb">json_encode</span><span class="p">([</span>
            <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'success'</span><span class="p">,</span>
            <span class="s1">'message'</span> <span class="o">=&gt;</span> <span class="nf">dt</span><span class="p">(</span><span class="s1">'Restore complete.'</span><span class="p">)</span>
        <span class="p">],</span> <span class="no">JSON_PRETTY_PRINT</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In which I publish a small Drush 11 command for Backup and Migrate.]]></summary></entry><entry><title type="html">Drupal 9: Fixing Google Charts rendering in tabbed pages</title><link href="https://blog.karimratib.me/2023/05/01/google-charts-tabs.html" rel="alternate" type="text/html" title="Drupal 9: Fixing Google Charts rendering in tabbed pages" /><published>2023-05-01T00:00:00+00:00</published><updated>2023-05-01T00:00:00+00:00</updated><id>https://blog.karimratib.me/2023/05/01/google-charts-tabs</id><content type="html" xml:base="https://blog.karimratib.me/2023/05/01/google-charts-tabs.html"><![CDATA[<p>Google Charts has a <a href="https://stackoverflow.com/search?q=google+charts+hidden">long-standing, known issue rendering correctly in hidden divs</a>. This caused us much head scratching and debugging hours before we even landed on the correct diagnosis: a chart that renders correctly on the <a href="https://git.drupalcode.org/project/charts/-/tree/5.0.x/modules/charts_api_example">Charts API Example page</a> does not work inside a tab! Oh, the joys of programming sometimes.</p>

<p>Once diagnosed, the fix was obvious: Detect that a tab is selected to refresh the charts contained therein. The following JavaScript file can be added to your theme as is and should handle the standard Bootstrap tabs (it also fixes the window resize event handling). It does depend on a small patch made to the <a href="https://git.drupalcode.org/project/charts/-/tree/5.0.x/modules/charts_google"><code class="language-plaintext highlighter-rouge">charts_google</code> module</a>, to avoid leaking memory when the graph is redrawn:</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">function </span><span class="p">(</span><span class="nx">$</span><span class="p">,</span> <span class="nx">Drupal</span><span class="p">,</span> <span class="nx">once</span><span class="p">)</span> <span class="p">{</span>
  <span class="p">(</span><span class="dl">"</span><span class="s2">use strict</span><span class="dl">"</span><span class="p">);</span>

  <span class="kd">function</span> <span class="nf">redrawGoogleChart</span><span class="p">(</span><span class="nx">element</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">contents</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Drupal</span><span class="p">.</span><span class="nx">Charts</span><span class="p">.</span><span class="nc">Contents</span><span class="p">();</span>
    <span class="kd">const</span> <span class="nx">chartId</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nx">id</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">.</span><span class="nx">charts</span><span class="p">.</span><span class="nf">hasOwnProperty</span><span class="p">(</span><span class="nx">chartId</span><span class="p">))</span> <span class="p">{</span>
      <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">.</span><span class="nx">charts</span><span class="p">[</span><span class="nx">chartId</span><span class="p">].</span><span class="nf">clearChart</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="kd">const</span> <span class="nx">dataAttributes</span> <span class="o">=</span> <span class="nx">contents</span><span class="p">.</span><span class="nf">getData</span><span class="p">(</span><span class="nx">chartId</span><span class="p">);</span>
    <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">.</span><span class="nf">drawChart</span><span class="p">(</span><span class="nx">chartId</span><span class="p">,</span> <span class="nx">dataAttributes</span><span class="p">[</span><span class="dl">'</span><span class="s1">visualization</span><span class="dl">'</span><span class="p">],</span> <span class="nx">dataAttributes</span><span class="p">[</span><span class="dl">'</span><span class="s1">data</span><span class="dl">'</span><span class="p">],</span> <span class="nx">dataAttributes</span><span class="p">[</span><span class="dl">'</span><span class="s1">options</span><span class="dl">'</span><span class="p">])();</span>
  <span class="p">}</span>

  <span class="nx">Drupal</span><span class="p">.</span><span class="nx">behaviors</span><span class="p">.</span><span class="nx">redrawGoogleCharts</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">attach</span><span class="p">:</span> <span class="nf">function </span><span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="nx">settings</span><span class="p">)</span> <span class="p">{</span>
      <span class="nf">$</span><span class="p">(</span><span class="dl">'</span><span class="s1">.nav-link</span><span class="dl">'</span><span class="p">,</span> <span class="nx">context</span><span class="p">).</span><span class="nf">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">shown.bs.tab</span><span class="dl">'</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">Drupal</span><span class="p">.</span><span class="nx">Charts</span> <span class="o">&amp;&amp;</span> <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">)</span> <span class="p">{</span>
          <span class="nf">$</span><span class="p">(</span><span class="dl">'</span><span class="s1">.charts-google</span><span class="dl">'</span><span class="p">,</span> <span class="nf">$</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">).</span><span class="nf">attr</span><span class="p">(</span><span class="dl">'</span><span class="s1">data-bs-target</span><span class="dl">'</span><span class="p">)).</span><span class="nf">each</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
            <span class="k">if </span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nf">hasOwnProperty</span><span class="p">(</span><span class="dl">'</span><span class="s1">chart</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
              <span class="nf">redrawGoogleChart</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
            <span class="p">}</span>
          <span class="p">});</span>
        <span class="p">}</span>
      <span class="p">});</span>

      <span class="nb">window</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">resize</span><span class="dl">'</span><span class="p">,</span> <span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">Drupal</span><span class="p">.</span><span class="nx">Charts</span> <span class="o">&amp;&amp;</span> <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">)</span> <span class="p">{</span>
          <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">.</span><span class="nf">waitForFinalEvent</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
            <span class="nf">$</span><span class="p">(</span><span class="dl">'</span><span class="s1">.charts-google</span><span class="dl">'</span><span class="p">).</span><span class="nf">each</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
              <span class="k">if </span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nf">hasOwnProperty</span><span class="p">(</span><span class="dl">'</span><span class="s1">chart</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
                <span class="nf">redrawGoogleChart</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
              <span class="p">}</span>
            <span class="p">});</span>
          <span class="p">},</span> <span class="mi">200</span><span class="p">,</span> <span class="dl">'</span><span class="s1">google-charts-redraw</span><span class="dl">'</span><span class="p">);</span>
        <span class="p">}</span>
      <span class="p">});</span>
    <span class="p">},</span>
  <span class="p">};</span>

<span class="p">})(</span><span class="nx">jQuery</span><span class="p">,</span> <span class="nx">Drupal</span><span class="p">,</span> <span class="nx">once</span><span class="p">);</span>
</code></pre></div></div>
<div class="language-patch highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh">diff --git a/modules/charts_google/js/charts_google.js b/modules/charts_google/js/charts_google.js
index f7abe81..76143bc 100755
</span><span class="gd">--- a/modules/charts_google/js/charts_google.js
</span><span class="gi">+++ b/modules/charts_google/js/charts_google.js
</span><span class="p">@@ -6,7 +6,7 @@</span>
<span class="err">
</span>   'use strict';
<span class="err">
</span><span class="gd">-  Drupal.googleCharts = Drupal.googleCharts || {charts: []};
</span><span class="gi">+  Drupal.googleCharts = Drupal.googleCharts || {charts: {}};
</span><span class="err">
</span>   /**
    * Behavior to initialize Google Charts.
<span class="p">@@ -122,6 +122,7 @@</span>
         options['colorAxis'] = {colors: colors};
       }
       chart.draw(data, options);
<span class="gi">+      Drupal.googleCharts.charts[chartId] = chart;
</span>     };
   };
</code></pre></div></div>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In which I describe a fix to a long-standing bug with Google Charts rendering inside hidden divs. This bug affects charts that are rendered in Boostrap tabs that are not active.]]></summary></entry></feed>