Notes on the JupyterLab Notebook HTML DOM Model, Part 6: Pulling an Extension Together

Following on from ..

To set up the project directory, I used the jupyterlab/extension-cookiecutter-ts. As part of the set-up prompt, this aked if I wanted to support settings, so I optimistically said yes and we’ll see if I can figure out how to make use of them!

For guidance on setting up the development environment, see

The cookie cutter creates lots of files, but I’ll try to ignore as many of them as I can for as long as I can and we’ll see how far we get…

There’s a single index.ts file in the src/ directory, so I’ll see if I can get by just using that. The style/base.css looks like the place to pop the style.

Let’s start with the style file. I’m going to create colour backgrounds for four activity types and pop the style into style/base.css:

.iou-activity-node {
    background-color: lightblue !important;
}

.iou-solution-node {
    background-color: lightgreen !important;
}

.iou-learner-node {
    background-color: yellow !important;
}

.iou-tutor-node {
    background-color: lightpink !important;
}

For the code itself, we can reuse building blocks we have already produced. This includes:

  • buttons to toggle cells;
  • notebook loader to class cells based on metadata.

The code will all be in src/index.ts to keep things as simple as possible. Here’s what the cookiecutter gives as a starter for ten, noting there is some new code I’ve not met before that will relate to handling settings files…

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';

import { ISettingRegistry } from '@jupyterlab/settingregistry';

/**
 * Initialization data for the jupyterlab-empinken extension.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  id: 'jupyterlab-empinken:plugin',
  autoStart: true,
  optional: [ISettingRegistry],
  activate: (app: JupyterFrontEnd, settingRegistry: ISettingRegistry | null) => {
    console.log('JupyterLab extension jupyterlab-empinken is activated!');

    if (settingRegistry) {
      settingRegistry
        .load(plugin.id)
        .then(settings => {
          console.log('jupyterlab-empinken settings loaded:', settings.composite);
        })
        .catch(reason => {
          console.error('Failed to load settings for jupyterlab-empinken.', reason);
        });
    }
  }
};

export default plugin;

Cribbing from the settings example extension, it looks like we can .get from the settings (as well as setting defaults in the script in advance). I’ve no idea where default config settings go in a settings file? In the example, there’s a schema/settings-example.json file. The cookiecutter created a schema/plugin.json file with a similar structure, and by inspection it looks like we can put default settings into the properties attribute.

Here’s a first attempt at my extension code, based on fragments retrieved from earlier posts in this series, and without any attempt at retrieving any properties:

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';

import { ISettingRegistry } from '@jupyterlab/settingregistry';

// START: TH added:
import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import { ToolbarButton } from '@jupyterlab/apputils';
import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';
// END: TH added

/**
 * Initialization data for the jupyterlab-empinken extension.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'jupyterlab-empinken:plugin',
  autoStart: true,
  optional: [ISettingRegistry]
};

export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    // Create a tag prefix
    // (this should be pulled from a setting()
    const tag_prefix = 'iou-';

    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
         
        // Get a single selected cell
        const activeCell = notebook.activeCell;
         
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...

        console.log("in and metadata is "+activeCell.model.metadata)
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList) {
          console.log("setting metadata..")
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
            console.log(" metadata is now "+activeCell.model.metadata)
        }
        console.log("continuing with metadata  "+activeCell.model.metadata)
         
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
         
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
         
        /*
        We can also toggle tags...
         
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
         
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
         
        // Set the tag name
        const tagname = tag_prefix + typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)
 
            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
             
            // Remove class
            activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
             
            // Add class
            activeCell.node.classList.add(tagname + "-node")
        }
         
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
         
    };
 
    // If we specify the buttons we want in an array
    // we can then construct them all via the same function
     
    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
     
    tagButtonSpec['activity'] = {'typ': 'activity', 'label': 'A', 'location': 10 };
    tagButtonSpec['solution'] = {'typ': 'solution', 'label': 'S', 'location': 11 }; 
    tagButtonSpec['learner'] = {'typ': 'learner', 'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor', 'label': 'T', 'location': 13 };
     
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ' + tag_prefix + typ + ' metadata tag on a cell',
        })
         
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }
     
    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
  }
}

/*
The most relevant docs appear to be:
https://jupyterlab.readthedocs.io/en/stable/api/modules/notebook.notebookactions.html
 
*/
export class ClassDemoExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
    /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    // Create a tag prefix
    // (this should be pulled from a setting()
    const tag_prefix = 'iou-';

    console.log("Tag prefix is " + tag_prefix);
    
    function check_tags(notebook){
        /*
        Iterate through all the cells
        If we see a cell with a tag that starts with class-
        then add a corresponding tag to differnt elements in the
        notebook HTML DOM
        */

        console.log("Checking to see if there are class tags to set on...")
        for (const cell of notebook.widgets) {
            let tagList = cell.model.metadata.get('tags') as string[];
            if (tagList) {
              for (let i = 0; i < tagList.length; i++) {
                var tag = tagList[i];
                console.log("I see tag " + tag);
                if (tag.startsWith(tag_prefix)) {
                  // class the cell
                  cell.node.classList.add(tag + "-node");
                } // end: if tag starts with prefix...
              } // end: tags iterator
            } //end: if there are tags
        } // end: iterate cells
    } // end: check_tags function definition
     
    notebook.modelChanged.connect((notebook) => {
        console.log("I think we're changed");
        // This doesn't seem to do anything on notebook load
        // iterate cells and toggle DOM classes as needed, e.g.
        //check_tags(notebook);
         
    });
         
    notebook.fullyRendered.connect((notebook) => {
        /*
        I don't think this means fully rendered on a cells becuase it seems
        like we try to add the tags mutliple times on notebook load
        which is really inefficient.
        This may be unstable anyway, eg the following comment:
            https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107
        I'll with go with in now in the expectation it will be break
        and I will hopefully be able to find another even to fire from.
         
        I get the impression the UI is some some of signal hell and
        the solution is just to keep redoing things on everything
        if anything changes. Who needs efficiency or elegance anyway...!
        After all, this is just an end-user hack-from-a-position-of-ignorance
        and works sort of enough to do something that I couldn't do before...
        */
         
        console.log("I think we're fullyRendered...(?!)");
        // iterate cells and toggle DOM classes as needed, e.g.
        check_tags(notebook);
    });
     
    return new DisposableDelegate(() => {
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
 function activate(app: JupyterFrontEnd, settingRegistry: ISettingRegistry | null): void {
    console.log('JupyterLab extension jupyterlab-empinken is activated!');
    console.log("YES WE'RE UPDATED 3")
    if (settingRegistry) {
      settingRegistry
        .load(plugin.id)
        .then(settings => {
          console.log('jupyterlab-empinken settings loaded:', settings.composite);
        })
        .catch(reason => {
          console.error('Failed to load settings for jupyterlab-empinken.', reason);
        });
    }

  app.docRegistry.addWidgetExtension('Notebook', new ClassDemoExtension());
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

export default plugin;

Of course, it failed to compile on first run, in part because of an unfound package, in part because of some compiler errors. Poking around, it looks as if you have to declare required packages in the package.json file under dependencies. These look like they need versioning, and I haven’t a clue. The latest version on npm is yesterday, which is probably not what I’m running, and from past experience I know that JupyterLab extensions can be really brittle at suffering from version conflicts. I feel I am in touching distance of getting an extension together, but even at these final steps there are blockers that just make you want to give and say f**k it. So assuming ^ means “at least version”, I’ll set a low one and hopefully be done with it given that things seem to work in the plugin playground. Which is to say, I added the dependency "@jupyterlab/notebook@jupyterlab/notebook": "^3.1.0" which was a version number in keeping with others…

As far as the other errors were concerned, these in part because of some unusused variables and some implicit any type declarations that the compiler took offence. If @martinRenou hadn’t noticed some of my frustration earlier in the week about the hostility of JupyterLab to have-a-go end-user developers and made a PR onto the cookiecutter extension (Disable noUnusedLocals option), I’d have possibly been a bit stuck at this point, but he did, so I was able to spot that the tsconfig.json file has various settings that set the level of compiler strictness, so I turned them off (e.g. setting "noImplicitAny": false, "noUnusedLocals": false and "strictNullChecks": false). FWIW, the last I saw the PR wasnlt being accepted; if I can get this extension working, and perhaps a cell status indicator extension, I’m going to go back to keeping as far as way as possible from JupyterLab extensions — it’s too hostile an environment for me!;-)

And then (after grief in the rebuild – I done a simple pip install --upgrade . on various rebuilds expecting that to work but of course it doesn’t. The cookie cutter README doesn’t give any adviace, but the jupyterlab/extension-example suggests the following dev process:

# go to the hello world example
cd EXTENSION_PROJECT_DIR

# install the extension in editable mode
python -m pip install -e .

# install your development version of the extension with JupyterLab
jupyter labextension develop . --overwrite

# build the TypeScript source after making changes
jlpm run build

# start JupyterLab
jupyter lab

I’m hoping when I restart my “daily” jupyter server up again the latest version of the extension is installed into it.

When it works, it looks like this (tagged cells highlighted); I need to tune the colours, but enough for now or I’ll miss the fish and chip shop, Friday as it is!

Looking at the code, it’s not that complex, but without @krassowksi answering my Stack Overflow question, and going further in the comments, and @martinRenou picking up on my ire and giving me a crib I needed in PR he added that seemed otherwise to get the thumbs down from folk, I would have walked away from this aagin, again, for another three months; so I am hugely grateful to them and will get a round in anytime our paths cross:-)

Of course, at the same time I am hugely grumpy and p****d off by the myriad tiny blockers that might be “just” minor inconveniences for folk who work with this stuff everyday.

Next up, I will try to port something approximating the cell execution status extension, though I really need to figure out settings for that if I try to add things like the audio accessibility elements. And once that’s done, I think that might be enough for our classic notebooks to run with our styling in JupyterLab. And then I will hopefllu be able to walk away from JupyterLab until they break, and look for alternative authoring and execution environments for MyST style documents. Long live Jupyter protocols, may the devil take JupyterLab!;-)

PS Argghh… forgot… I need to add OUr logo, but various issues languishing for years etc; if you know a hack, please share it via the comments; in classic notebook my solution was just to dodge the old logo out of the way with a bit of CSS and add our one in… Here are a couple of possible cribs: adding a custom splash screen to JuyterLab; Jupyterlite logo extension (issue and PR). See also this PR on updating the JupyterLab logo.

PPS more JupyterLab logo cribs: DOM cell ID is jp-MainLogo ; creating a logo widget: https://github.com/jupyterlab/jupyterlab/blob/7f87d89dc88ad86e84fb2245c873139ce60dec3c/packages/application-extension/src/index.tsx#L1034 ; uses import {jupyterIcon} from @jupyterlab/ui-components. Defined at https://github.com/jupyterlab/jupyterlab/blob/95fc6e74a21953c44a17a73c3d6741ac66320979/packages/ui-components/src/icon/iconimports.ts#L146 by way of import jupyterSvgstr from '../../style/icons/jupyter/jupyter.svg'; So it looks like we should crate an extension to add a “CustomLogo: JupyterFrontEndPlugin” based on const JupyterLogo: JupyterFrontEndPlugin<void>, remove the current Logo somehow, and apply the new logo. There’s also an old hack similar to one I used in classic notebook: https://discourse.jupyter.org/t/change-icons-in-theme-extension/3851/8 .

Author: Tony Hirst

I'm a Senior Lecturer at The Open University, with an interest in #opendata policy and practice, as well as general web tinkering...

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: