1

I am working with Primo, an app written in AngularJS by the company Ex Libris. It's a library catalog, allowing users to search the library's physical and digital collections. I do not have access to the original AngularJS code, but I do have the ability to define and attach AngularJS modules.

My current objective is simple on the face of it: I need a link from Primo out to a third-party app, including a query string containing the ID of the item the patron is looking at, e.g. https://library.example.edu/ill/primo.php?docID=cdi_proquest_ebookcentral_EBC7007769.

I was able to add the link using the Primo control panel. It let me set the URL, the link text and so on. Here is a screenshot of that bit of the control panel:

Screenshot of Primo's General Electronic Services link editor.

The URL Template lets you enter the URL for your link, and has some capacity to put in tokens that will be replaced with data about the item, e.g. {rft.atitle} would be replaced with the title of an article. Sadly, it does not provide a way to insert the docID. I tried putting in an AngularJS token, like {{pnx.control.recordid[0]}}, which I think would get the data I need. But the UI rejected it as an invalid parameter. So I specified my URL as https://example.com/incoming.php?docID=replaceMe. The plan was to write JS to retrieve the docID from the live URL and update the link before the user has a chance to click it.

Unfortunately, Primo created the link as an AngularJS component. Here is the code it generates when the link is built in an actual page:

<md-list-item ng-repeat="service in $ctrl.filteredServices() track by $index" role="listitem">
    <button type="button" ng-transclude="" (click)="$ctrl.onClick(service)" aria-label="Interlibrary Loan, opens in a new window">
        <span ng-if="service['service-type'] === 'OvL'">
            <a
                ng-href="https://library.example.edu/ill/primo.php?docID=replaceMe"
                ng-click="$event.preventDefault()"
                translate="Interlibrary Loan"
                href="https://library.example.edu/ill/primo.php?docID=replaceMe"
            >Interlibrary Loan</a>
        </span>
    </button>
</md-list-item>

For the sake of clarity I have removed some container DIVs and most of the styling information. I also cleaned up the indentation.

As you can see, the link shows up with both an ng-href attribute and an href attribute recording the URL I entered. I wrote JavaScript to update those. But it had no effect. Updating the attributes and then clicking on the link takes you to the original unmodified URL. I believe that what's happening is that AngularJS is recording the original URL somewhere in its data structure when it processes the URL, then disabling the normal onClick handler for the link. When you click the link, AngularJS sends you to the URL it stored, not the one currently recorded in the tag's attributes.

You may also note that it added a <button> wrapped around the link. That duplicates the functionality of the link: tapping or clicking it takes you to the same URL. I think it's there to increase the clickable area, but complicates things because the URL needs to be updated in two places.

All I need is to swap replaceMe for the actual docID and update that URL. I could brute force this by using DOM manipulation to clone the entire HTML structure that AngularJS produced, delete the original and then assign my own onClick handlers to the button and the link. But I would rather work with AngularJS rather than fighting against it.

So my questions are:

  1. How can I figure out where AngularJS is saving the URL? I don't know where to look.
  2. Once I've found it, is there an AngularJS-specific way to update it? Some frameworks get tetchy when you twiddle their bits without using their own methods.

Googling led me to people writing AngularJS code, not updating existing data at run time. The documentation makes similar assumptions. I've been working on this for three days now, and I'm getting nowhere. Any help would be appreciated.

EDIT, six days later:

I am getting closer. And yet so far.

The first thing I did not understand was the the original developers at Ex Libris have disabled debug info:

$compileProvider.debugInfoEnabled(false)

After more googling I found a Firefox browser extension called AngularScope which promised to let me examine the scope of arbitrary DOM elements. When I installed it, it let me know that debug info was disabled. That let me learn that I can turn that back on from the console using:

angular.reloadWithDebugInfo()

Once I learned that I was able to determine that the scope for my link contains an object named service with a property named link-to-service. That's where it's storing the URL. So I got a reference to the link I was looking at in a variable named myLink and wrote the following:

angular.element(myLink).scope().$apply(function () {
    angular.element(myLink).scope().service["link-to-service"] = newURL;
});

And it works! It updates the URL, and then clicking the link (AND the button) goes to the correct page.

But.

But it only works when AngularJS's debug info is enabled. Which, by default, it is not. The AngularJS scopes do not get attached to the DOM, and so angular.element(myLink).scope() is undefined and useless.

One of the answers on the question AngularJs scope variables in console suggested that I could get the scopes back by executing the following code:

var scope;
angular.element(document.body).injector().invoke(function ($rootScope) {
    scope = $rootScope;
    console.log(scope);
});

Unfortunately it runs into the same problem, reporting that angular.element(document.body).injector() is not defined. I suspect that may have been a change made after that answer was filed in 2015.

I tried defining a new AngularJS module and using its .run() method to manipulate the DOM after AngularJS bootstrapped itself but before it compiled its templates. It had no effect; probably because by the time my module got loaded in (well after page load time) AngularJS has already compiled at least once.

I thought about setting up an event handler using onDOMContentLoaded to update the URL before Angular could get to it. But then realized that wouldn't work because my JS is being added to the page by AngularJS. By the time my script ran, that event would already be long in the past.

So I guess the question has now become: How do I get access to the scope of an AngularJS component when debug info is not enabled?

If I could just get access to the scope, updating the URL is trivial. And yet, somehow I've been working on this for nine days now. I am getting sorely tempted to just write JS to rip out the entire HTML structure that AngularJS built and then rebuild it myself, just so I can update the URL. It would be awkward and horrifically inefficient. But it would work, and then I could move on with my life.

Will Martin
  • 4,142
  • 1
  • 27
  • 38

1 Answers1

0

Okay. I finally figured it out, after ages poring over documentation, StackOverflow answers, random web sites and reading snippets of the AngularJS code.

When $compileProvider.debugInfoEnabled(false) is set, scopes are not attached to the document. But you can still get at them. It just takes some doing.

In this StackOverflow answer, the user Estus Flask provided this bit of code:

var scope;
angular.element(document.body).injector().invoke(function ($rootScope) {
  scope = $rootScope;
  console.log(scope);
});

When I tried this, it did not work. But the problem turned out to be that the <body> of the particular app I'm working with is not connected to AngularJS. Once I targeted an element that WAS initialized by AngularJS, it worked immediately. In my case, the meant targeting the <primo-explore> element that provides the root of the app. Also, from a comment by user sahbeewah on this helpful answer with a diagram of AngularJS scopes, I got some slightly more concise syntax for the same thing.

So to get your root scope, first identify which element is the root of your app. It may have an ng-app attribute, or it may be a custom element like the <primo-explore> element. Either way, you can then get the root scope like so:

let angRoot = document.getElementsByTagName("primo-explore")[0];
let rootScope = angular.element(angRoot).injector().get('$rootScope')

In my case getElementsByTagName made sense. In other cases you may need to use getElementById or getElementsByQuerySelector.

So now we have the root scope. Next, we can use that to get all the other defined scopes, using this helpful recursive function, courtesy of the user larspars on the question how do you find all the scopes on a page?

function getScopes(root) {
    var scopes = [];

    function visit(scope) {
        scopes.push(scope);
    }
    function traverse(scope) {
        visit(scope);
        if (scope.$$nextSibling)
            traverse(scope.$$nextSibling);
        if (scope.$$childHead)
            traverse(scope.$$childHead);
    }

    traverse(root);
    return scopes;
}

getScopes($rootScope)

After running that, I had an array of a bit over 600 scopes. But I had them. After that it was just a matter of iterating through the list, looking for the one I wanted. A for loop was all it took:

var cur; // the current scope
var curLink; // the current link from the scope

for (let i = 0; i < scopes.length; i++) {
    cur = scopes[i];

    // Skip any scopes that do not have a property named
    // service.
    if(typeof(cur.service) == "undefined"){ continue; }

    // Make sure the link to service is there.
    if(typeof(cur.service["link-to-service"]) == "string"){
        // Get it.
        curLink = cur.service["link-to-service"];

        // Check to see if it's OUR link.
        if(curLink == devLink || curLink == prodLink){
            // Hooray! Update it.
            cur.$apply(function(){
                cur.service["link-to-service"] = newURL;
            });
        }
    }
}

After that, I did some cleanup, such as setting scopes = undefined; because we don't really need a duplicate copy of all 600+ scopes sucking up memory needlessly, and updating the original HREF on the link so that a user hovering their mouse over it will see the correct link listed in the tooltip at the bottom.

Victory is mine! And all it took was almost two work-weeks to update a single link.

Will Martin
  • 4,142
  • 1
  • 27
  • 38