Creating a Chrome Extension for Diigo, Part 3
In part 1, we introduced some new concepts and built a skeleton version of our extension, ready for installation and testing. Part 2 then took us through some helper methods and error handling, as well as parsing the result we got from Diigo and filtering out unique tags.
In Part 3 of this series, we'll write the body of our extension using everything we've done so far.
Preparation
I cleaned up the background.js
file we made in the previous parts so go ahead and grab its content from Github. It's essentially identical, just reformatted and restructured slightly.
Listeners for bookmark events
The first thing we'll do is add some listeners for bookmark events. Specifically, when a bookmark creation, change or deletion occurs, we want Diigo to know about it.
chrome.bookmarks.onCreated.addListener(function (id, node) {
chrome.bookmarks.get(node.parentId, function (parent) {
if (parent !== false) {
chrome.bookmarks.get(parent[0].parentId, function (grandparent) {
/** @namespace grandparent.title */
if (grandparent[0] !== false && grandparent[0].title == "Tags") {
// Bookmark was created in proper location, send to Diigo
doRequest(node, parent[0].title);
}
});
}
});
});
chrome.bookmarks.onRemoved.addListener(function (id, removeInfo) {
// To be added when API supports it
});
chrome.bookmarks.onChanged.addListener(function (id, changeInfo) {
// To be added when API supports it
});
The bottom two listeners are just placeholders, because Diigo doesn't support this functionality yet. I'm told their API is soon to be upgraded, though, so we're putting them there nonetheless.
The onCreated
listener first checks if the created bookmark node has a parent. If it does, then it checks the name of that parent's parent – and if that name is "Tags" we know we've got the right folder and we need to submit to Diigo. Now, this function assumes you have no other double-parent bookmark with "Tags" as the grandparent but, theoretically, it could happen. To check against that, we would need to add yet another parent level check for the Diigo master folder, but I'll leave that up to you for homework.
We then call doRequest
with two parameters: the actual bookmark node that was created, and the name of the tag folder it was created in. Obviously, we need this data to tell Diigo which bookmark to create and which tag to give it. But why doRequest
? Isn't that our "GET" function? Yep – but as you'll see in a moment, we'll modify it so that it can handle both the POST and GET action of our extension.
What we need to do next is add these params to our doRequest
function, and have it react to their presence or absence, like so:
var doRequest = function (bookmarknode, tag) { var xml = new XMLHttpRequest(); if (bookmarknode !== undefined) { if (tag === undefined) { console.error("Tag not passed in. Unaware of where to store bookmark in Diigo. Nothing done."); } else { // Bookmark node was passed in. We're doing a POST for update, create or delete // Currently only create is supported var uriPart = encodeURI("url=" + bookmarknode.url + "&title=" + bookmarknode.title + "&tags=" + tag); xml.open('POST', rootUrl + uriPart); xml.setRequestHeader('Authorization', auth); xml.send(); xml.onreadystatechange = function () { if (xml.readyState === 4) { if (xml.status === 200) { clog("Successfully created new bookmark in Diigo"); } else { if (possibleErrors
!== undefined) {
console.error(xml.status + ' ' + possibleErrors);
} else {
console.error(possibleErrors.other);
}
}
}
};
}} else {
xml.open('GET', rootUrl + "&count=100&filter=all&user="+user);
xml.setRequestHeader('Authorization', auth);
xml.send();xml.onreadystatechange = function () {
if (xml.readyState === 4) {
if (xml.status === 200) {
process(JSON.parse(xml.responseText));
} else {
if (possibleErrors!== undefined) {
console.error(xml.status + ' ' + possibleErrors);
} else {
console.error(possibleErrors.other);
console.error(xml.status);
}
}
}
};
}
};If the
bookmarknode
andtag
params are provided and valid, we execute the XHR request almost the same way as we did the original one for GETting the bookmarks, with one crucial difference – this time, we make it a POST request and add the title, tag, and bookmark name into the URL. That's all that's needed – now Diigo can accept our POST request and react accordingly. This is the beauty of RESTful API design.Root bookmarks
Now let's save all the BBS-root bookmarks. We already have them in an array from the initial looping in the
process
function, but we don't do anything with them. Let's change that.In Part 2, we made sure the "Diigo #BBS" folder exists. Once we're certain that it does, we can initiate the creation of the root bookmarks – they have a home we can put them in at that point.
Rewrite the part of the
process
function from this:var folderName = 'Diigo #BBS'; chrome.bookmarks.getFirstChildByTitle("1", folderName, function(value) { if (value === false) { chrome.bookmarks.create({ parentId: "1", title: folderName }, function (folder) { console.log(folderName + " not found and has been created at ID " + folder.id); }); } });
to
var folderName = 'Diigo #BBS'; chrome.bookmarks.getFirstChildByTitle("1", folderName, function(value) { if (value === false) { chrome.bookmarks.create({ parentId: "1", title: folderName }, function (folder) { clog(folderName + " not found and has been created at ID " + folder.id); processTagsFolder(folder, allTags); }); } else { processTagsFolder(value, allTags); } });
As you can see, we added a new call to a
processTagsFolder
function. This function gets the "Diigo #BBS" folder passed in as the first parameter, and the array of all tags as the second. Seeing as this method executes either way – whether the "Diigo #BBS" folder pre-existed or not, we can put our root-bookmark-creation logic inside it./** * Creates the Tags master folder if it doesn't exist * Initiates the check for tag subfolders * Creates ROOT bookmarks * @param rootNode * @param tagsArray */ function processTagsFolder(rootNode, tagsArray) { // Get all current root bookmarks, if any chrome.bookmarks.getChildren(rootNode.id, function (currentRoots) { var crl = currentRoots.length; var ignoredUrls = []; var rootNumOrig = rootBookmarks.length; if (crl) { var bAmongThem = false; var rootNum = rootNumOrig; // Iterate through all the current items in the root folder while (crl--) { // Check if current item is a URL bookmark, not a folder if (currentRoots[crl].hasOwnProperty('url')) { // Iterate through downloaded bookmarks to see if it's among them bAmongThem = false; while (rootNum--) { if (rootBookmarks[rootNum].url == currentRoots[crl].url) { // Found among existing! bAmongThem = true; if (rootBookmarks[rootNum].title != currentRoots[crl].title) { // Does title need updating? chrome.bookmarks.update(currentRoots[crl].id, { title: rootBookmarks[rootNum].title }); } // Ignore this URL when later adding the downloaded root bookmarks ignoredUrls.push(rootBookmarks[rootNum].url); break; } } if (!bAmongThem) { // Does not exist in downloaded - needs to be deleted from browser chrome.bookmarks.remove(currentRoots[crl].id); } } } } // At this point, we know we removed all the bookmarks that are no longer in our Diigo account // Now let's add those that are left while (rootNumOrig--) { if (ignoredUrls.indexOf(rootBookmarks[rootNumOrig].url) === -1) { chrome.bookmarks.create({ url: rootBookmarks[rootNumOrig].url, title: rootBookmarks[rootNumOrig].title, parentId: rootNode.id }); } } }); }
In short, what we do here is fetch all the current root bookmarks, see if they're among the freshly downloaded ones and delete them if they're not (that means they've been untagged as bbs-root in Diigo), and finally, we add all the others. If you try it out, this should work wonderfully.
We also need to create the Tags folder if it doesn't exist. Add the following code right under the last bit:
chrome.bookmarks.getFirstChildByTitle(rootNode.id, 'Tags', function (tagsFolder) { if (tagsFolder === false) { chrome.bookmarks.create({ parentId: rootNode.id, title: "Tags" }, function (folder) { processTags(folder, tagsArray); }); } else { processTags(tagsFolder, tagsArray); } });
Obviously, we've made another function that gets called regardless of whether or not the Tags folder pre-existed. Let's define
processTags
.Processing Tags
/** * Creates all non-existent tag subfolders. * Removes all tag subfolders that do not have any bookmarks. * @param tagsFolder * @param tagsArray */ function processTags(tagsFolder, tagsArray) { // Remove all unused tag subfolders chrome.bookmarks.getChildren(tagsFolder.id, function (currentTagSubfolders) { var numCurrentTags = currentTagSubfolders.length; if (numCurrentTags > 0) { var currentTags = []; var currentTagsIds = {}; var cTag; while (numCurrentTags--) { cTag = currentTagSubfolders[numCurrentTags]; currentTags.push(cTag.title); currentTagsIds[cTag.title] = cTag.id; } var diff = currentTags.diff(allTags, false); var numUnused = diff.length; if (numUnused) { while (numUnused--) { chrome.bookmarks.removeTree(currentTagsIds
]);
}
}
}
});// Create necessary tag subfolders
var numTags = tagsArray.length;
while (numTags--) {
let title = tagsArray[numTags];
chrome.bookmarks.getFirstChildByTitle(tagsFolder.id, title, function (tagFolder) {
if (tagFolder === false) {
// Needs to be created
chrome.bookmarks.create({
parentId: tagsFolder.id,
title: title
}, function (folder) {
addAllBookmarksWithTag(folder);
});
} else {
addAllBookmarksWithTag(tagFolder);
}
});
}
}The above function filters out the difference between the
AllTags
array (the list of tags we fetched fresh from Diigo and made unique) and the tag sub folders currently present in the "Tags" folder. This difference represents those folders in the Chrome Bookmark Bar which no longer have any members in the user's Diigo library. Thus, these folders are removed from Chrome.After the cleanup is done, the function iterates through the list of tags most recently downloaded from Diigo, and creates those sub folders, after which the
addAllBookmarksWithTag
function is called.Adding bookmarks to a tag subfolder
/** * Adds all bookmarks with given tag to provided folder, if they don't exist. * Looks at URL for comparison, not title. * @param folder */ function addAllBookmarksWithTag(folder) { chrome.bookmarks.getChildren(folder.id, function (children) { var urls = {}; if (children.length > 0) { var numChildren = children.length; var subItem; while (numChildren--) { subItem = children[numChildren]; urls[subItem.url] = subItem; } } var i = iLength; var key = false; while (i--) { var item = response[i]; var tags = item.tags.split(','); if (tags.indexOf(folder.title) > -1) { // Bookmark belongs in folder if (urls.hasOwnProperty(item.url)) { key = item.url; } if (urls.hasOwnProperty(item.url + "/")) { key = item.url + "/"; } if (key) { // Bookmark already exists in folder if (urls[key].title != item.title) { // Title needs an update clog('Title updated: "' + urls[key].title + '" to "' + item.title + '"'); chrome.bookmarks.update(urls[key].id, {title: item.title}); } } else { // Bookmark needs to be created chrome.bookmarks.create({ parentId: folder.id, title: item.title, url: item.url }, function (bookmarkItem) { clog("Created Item: " + bookmarkItem.title + " on " + bookmarkItem.url); }); } } } }); }
Finally, we add the bookmarks to their respective tag folders. We first build an object containing current bookmark URLs from each folder as keys, and the bookmark nodes themselves as values.
The function iterates through the original result set, splits the tags and checks if the bookmark it's currently dealing with belongs into the current folder. The "/" trick is due to Diigo sometimes pushing random slashes onto URLs. We'll handle this in a followup "Optimizations" article. If the bookmark belongs into the folder, and is already inside, the function checks if the bookmark title needs updating. If so, it updates it. If the bookmark doesn't exist in the folder, and it should, then it is created.
Conclusion
At long last, we built the most of our extension. There's still some quirks to iron out, but most of the work is done. You can download the final version of
background.js
from Github.In Part 4, we'll focus on letting people log into the extension and use their own account, we'll allow adding of custom API keys in case of quota problems, and we'll optimize our code a bit. Stay tuned!