blob: 880ea4e3ef34a40fcb61e13b312ea79fbbfac619 [file] [log] [blame]
//Run when the document loads AND we have the config loaded.
(function() {
"use strict";
var config;
var conversions;
Promise.all([new Promise(function(resolve, reject) {
$.ajax('config.json').done(function(data) {
config = data;
resolve();
}).fail(function() {
console.error("Failed to get config.");
ga('send', 'event', 'error', 'config');
reject();
});
}), new Promise(function(resolve, reject) {
var timeout = setTimeout(function() {
console.error("Document never loaded? Something bad has happened!");
reject();
}, 10000);
$(function() {
clearTimeout(timeout);
resolve();
});
}), new Promise(function(resolve, reject) {
$.getJSON('../conversions.json').done(function(data) {
conversions = data;
resolve();
}).fail(function() {
console.error("Failed to get conversions.");
ga('send', 'event', 'error', 'config');
//reject(); We will continue anyways. We don't need this functionality.
conversions = {};
resolve();
});
})]).then(function() {
var getParameterByName = function(name) {
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)");
var results = regex.exec(location.search);
return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
//Overwrite config if present. Any failure will just cause this to be skipped.
try {
var configParam = JSON.parse(getParameterByName('config'));
config = jQuery.extend(true, config, configParam);
} catch (e) {
console.warn("Failure in config overwrite, skipping.", e);
}
new Atmos(config,conversions);
}, function() {
console.error("Failed to initialize!");
ga('send', 'event', 'error', 'init');
});
})();
var Atmos = (function() {
"use strict";
var closeButton = '<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>';
var guid = function() {
var d = new Date().getTime();
var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
return uuid;
}
/**
* Atmos
* @version 2.0
* Configures an Atmos object. This manages the atmos interface.
* @constructor
* @param {string} catalog - NDN path
* @param {Object} config - Object of configuration options for a Face.
*/
var Atmos = function(config, conversions) {
//Internal variables.
this.results = [];
this.resultCount = Infinity;
this.name = null ;
this.page = 0;
this.resultsPerPage = 25;
this.retrievedSegments = 0;
//Config/init
this.config = config;
this.conversions = conversions;
this.catalog = config['global']['catalogPrefix'];
this.catalogPrefix = new Name(this.catalog);
this.face = new Face(config['global']['faceConfig']);
//Easy access dom variables
this.categories = $('#side-menu');
this.resultTable = $('#resultTable');
this.filters = $('#filters');
this.searchInput = $('#search');
this.searchBar = $('#searchBar');
this.searchButton = $('#searchButton');
this.resultMenu = $('.resultMenu');
this.alerts = $('#alerts');
this.requestForm = $('#requestForm');
var scope = this;
$('.requestSelectedButton').click(function() {
ga('send', 'event', 'button', 'click', 'request');
scope.request(scope.resultTable.find('.resultSelector:checked:not([disabled])').parent().parent());
});
this.filterSetup();
//Init autocomplete
this.searchInput.autoComplete(function(field, callback) {
ga('send', 'event', 'search', 'autocomplete');
scope.autoComplete(field, function(data) {
var list = data.next;
var last = data.lastComponent === true;
callback(list.map(function(element) {
return field + element + (last ? "/" : "");
//Don't add trailing slash for last component.
}));
});
});
//Handle search
this.searchBar.submit(function(e) {
ga('send', 'event', 'search', 'submit');
e.preventDefault();
if (scope.searchInput.val().length === 0) {
if (!scope.searchBar.hasClass('has-error')) {
scope.searchBar.addClass('has-error').append('<span class="help-block">Search path is required!</span>');
}
return;
} else {
scope.searchBar.removeClass('has-error').find('.help-block').fadeOut(function() {
$(this).remove()
});
}
scope.pathSearch();
});
this.searchButton.click(function() {
console.log("Search Button Pressed");
ga('send', 'event', 'button', 'click', 'search');
scope.search();
});
//Result navigation handlers
this.resultMenu.find('.next').click(function() {
ga('send', 'event', 'button', 'click', 'next');
if (!$(this).hasClass('disabled')) {
scope.getResults(scope.page + 1);
}
});
this.resultMenu.find('.previous').click(function() {
ga('send', 'event', 'button', 'click', 'previous');
if (!$(this).hasClass('disabled')) {
scope.getResults(scope.page - 1);
}
});
this.resultMenu.find('.clearResults').click(function() {
ga('send', 'event', 'button', 'click', 'resultClear');
scope.clearResults();
$('#results').fadeOut(function() {
$(this).addClass('hidden');
});
});
//Change the number of results per page handler
var rpps = $('.resultsPerPageSelector').click(function() {
var t = $(this);
if (t.hasClass('active')) {
return;
}
rpps.find('.active').removeClass('active');
t.addClass('active');
scope.resultsPerPage = Number(t.text());
scope.getResults(0);
//Force return to page 1;
});
//Init tree search
$('#treeSearch div').treeExplorer(function(path, callback) {
console.log("Tree Explorer request", path);
ga('send', 'event', 'tree', 'request');
scope.autoComplete(path, function(data) {
var list = data.next;
var last = (data.lastComponent === true);
if (last) {
console.log("Redirecting last element request to a search.");
scope.clearResults();
scope.query(scope.catalog, {
'??': path
}, function(interest, data) {
console.log("Search response", interest, data);
scope.name = interest.getName();
scope.getResults(0);
}, function(interest) {
console.warn("Failed to retrieve final component.", interest, path);
scope.createAlert("Failed to request final component. " + path + " See console for details.");
});
return;
//Don't call the callback
}
console.log("Autocomplete response", list);
callback(list.map(function(element) {
return (path == "/" ? "/" : "") + element + "/";
}));
});
});
$('#treeSearch').on('click', '.treeSearch', function() {
var t = $(this);
scope.clearResults();
var path = t.parent().parent().attr('id');
console.log("Tree search:", path);
scope.query(scope.catalog, {
'??': path
}, function(interest, data) {
//Success
console.log("Tree search response", interest, data);
scope.name = interest.getName();
scope.getResults(0);
}, function(interest) {
//Failure
console.warn("Request failed! Timeout", interest);
scope.createAlert("Request timed out.\"" + interest.getName().toUri() + "\" See console for details.");
});
});
this.setupRequestForm();
this.resultTable.popover({
selector: ".metaDataLink",
content: function() {
$('.metaDataLink').not(this).popover('destroy');
return scope.getMetaData(this);
},
title: "Metadata",
html: true,
trigger: 'click',
placement: 'bottom'
});
this.resultTable.on('click', '.metaDataLink', function(e) {
//This prevents the page from scrolling when you click on a name.
e.preventDefault();
});
this.resultTable.on('click', '.subsetButton', function() {
var metaData = $(this).siblings('pre').text();
var exp = /netcdf ([\w-]+)/;
var match = exp.exec(metaData);
var filename = match[0].replace(/netcdf /, '') + '.nc';
scope.request(null , filename);
});
//Allow the title to change the tab
$('#brand-title').click(function() {
//Correct active class on tabs.
$('#path-search-tab').removeClass('active');
$('#tree-search-tab').removeClass('active');
$('#search-tab').addClass('active');
});
}
Atmos.prototype.clearResults = function() {
this.results = [];
//Drop any old results.
this.retrievedSegments = 0;
this.resultCount = Infinity;
this.page = 0;
this.resultTable.empty();
}
Atmos.prototype.pathSearch = function() {
var value = this.searchInput.val();
this.clearResults();
var scope = this;
this.query(this.catalog, {
"??": value
}, function(interest, data) {
console.log("Query response:", interest, data);
scope.name = interest.getName();
scope.getResults(0);
}, function(interest) {
console.warn("Request failed! Timeout", interest);
scope.createAlert("Request timed out. \"" + interest.getName().toUri() + "\" See console for details.");
});
}
Atmos.prototype.search = function() {
var filters = this.getFilters();
console.log("Search started!", this.searchInput.val(), filters);
console.log("Initiating query");
this.clearResults();
var scope = this;
this.query(this.catalog, filters, function(interest, data) {
//Response function
console.log("Query Response:", interest, data);
scope.name = interest.getName();
scope.getResults(0);
}, function(interest) {
//Timeout function
console.warn("Request failed after 3 attempts!", interest);
scope.createAlert("Request failed after 3 attempts. \"" + interest.getName().toUri() + "\" See console for details.");
});
}
Atmos.prototype.autoComplete = function(field, callback) {
var scope = this;
var result = {};
const getAll = function(interest, data) {
if (data.getContent().length !== 0) {
var resp = JSON.parse(data.getContent().toString().replace(/[\n\0]/g, ""));
if (result.next) {
result.next = result.next.concat(resp.next);
} else {
result = resp;
}
} else {
callback(result);
}
var name = data.getName();
var segment = name.components[name.getComponentCount() - 1];
if (segment.toSegment() !== data.getMetaInfo().getFinalBlockId().toSegment()) {
name = name.getPrefix(-1);
//Remove segment
name.appendSegment(segment.toSegment() + 1);
scope.expressInterest(name, getAll, function() {
console.warn("Autocomplete timed out, results may be incomplete.");
callback(result);
//Return if we get a timeout.
});
} else {
callback(result);
}
}
this.query(this.catalog, {
"?": field
}, getAll);
}
Atmos.prototype.showResults = function(resultIndex) {
var results = this.results.slice(this.resultsPerPage * resultIndex, this.resultsPerPage * (resultIndex + 1));
var resultDOM = $(results.reduce(function(prev, current) {
prev.push('<tr><td><input class="resultSelector" type="checkbox"></td><td class="popover-container"><a href="#" class="metaDataLink">');
prev.push(current);
prev.push('</a></td></tr>');
return prev;
}, ['<tr><th><input id="resultSelectAll" type="checkbox"> Select All</th><th>Name</th></tr>']).join(''));
resultDOM.find('#resultSelectAll').click(function() {
if ($(this).is(':checked')) {
resultDOM.find('.resultSelector:not([disabled])').prop('checked', true);
} else {
resultDOM.find('.resultSelector:not([disabled])').prop('checked', false);
}
});
this.resultTable.hide().empty().append(resultDOM).slideDown('slow');
this.resultMenu.find('.pageNumber').text(resultIndex + 1);
this.resultMenu.find('.pageLength').text(this.resultsPerPage * resultIndex + results.length);
if (this.resultsPerPage * (resultIndex + 1) >= this.resultCount) {
this.resultMenu.find('.next').addClass('disabled');
} else if (resultIndex === 0) {
this.resultMenu.find('.next').removeClass('disabled');
}
if (resultIndex === 0) {
this.resultMenu.find('.previous').addClass('disabled');
} else if (resultIndex === 1) {
this.resultMenu.find('.previous').removeClass('disabled');
}
$.scrollTo("#results", 500, {
interrupt: true
});
}
Atmos.prototype.getResults = function(index) {
var scope = this;
if ($('#results').hasClass('hidden')) {
$('#results').removeClass('hidden').slideDown();
}
if ((scope.results.length === scope.resultCount) || (scope.resultsPerPage * (index + 1) < scope.results.length)) {
//console.log("We already have index", index);
scope.page = index;
scope.showResults(index);
return;
}
if (scope.name === null ) {
console.error("This shouldn't be reached! We are getting results before a search has occured!");
throw new Error("Illegal State");
}
var interestName = new Name(scope.name);
// Interest name should be /<catalog-prefix>/query/<query-param>/<version>/<#seq>
if (scope.name.size() === (scope.catalogPrefix.size() + 3)) {
interestName = interestName.appendSegment(scope.retrievedSegments++);
//console.log("Requesting data index: (", scope.retrievedSegments - 1, ") at ", interestName.toUri());
}
this.expressInterest(interestName, function(interest, data) {
//Response
if (data.getContent().length === 0) {
scope.resultMenu.find('.totalResults').text(0);
scope.resultMenu.find('.pageNumber').text(0);
scope.resultMenu.find('.pageLength').text(0);
console.log("Empty response.");
scope.resultTable.html("<tr><td>Empty response. This usually means no results.</td></tr>");
return;
}
var content = JSON.parse(data.getContent().toString().replace(/[\n\0]/g, ""));
if (!content.results) {
scope.resultMenu.find('.totalResults').text(0);
scope.resultMenu.find('.pageNumber').text(0);
scope.resultMenu.find('.pageLength').text(0);
console.log("No results were found!");
scope.resultTable.html("<tr><td>No Results</td></tr>");
return;
}
scope.results = scope.results.concat(content.results);
scope.resultCount = content.resultCount;
scope.resultMenu.find('.totalResults').text(scope.resultCount);
scope.page = index;
// reset scope.name
scope.name = new Name(data.getName().getPrefix(scope.catalogPrefix.size() + 3));
scope.getResults(index);
//Keep calling this until we have enough data.
}, function() {});//Ignore failure
}
Atmos.prototype.query = function(prefix, parameters, callback, timeout) {
var queryPrefix = new Name(prefix);
queryPrefix.append("query");
var jsonString = JSON.stringify(parameters);
queryPrefix.append(jsonString);
this.expressInterest(queryPrefix, callback, timeout);
}
Atmos.prototype.expressInterest = function(name, success, failure) {
var interest = new Interest(name);
interest.setInterestLifetimeMilliseconds(500);
interest.setMustBeFresh(true);
const face = this.face;
async.retry(4, function(done) {
face.expressInterest(interest, function(interest, data) {
done();
success(interest, data);
}, function(interest) {
done("Interest timed out 4 times.", interest);
});
}, function(err, interest) {
if (err) {
console.log(err, interest);
failure(interest);
}
});
}
/**
* This function returns a map of all the categories active filters.
* @return {Object<string, string>}
*/
Atmos.prototype.getFilters = function() {
var filters = this.filters.children().toArray().reduce(function(prev, current) {
var data = $(current).text().split(/:/);
prev[data[0]] = data[1];
return prev;
}, {});
//Collect a map<category, filter>.
//TODO Make the return value map<category, Array<filter>>
return filters;
}
/**
* Creates a closable alert for the user.
* @param {string} message
* @param {string} type - Override the alert type.
*/
Atmos.prototype.createAlert = function(message, type) {
var alert = $('<div class="alert"><div>');
alert.addClass(type ? type : 'alert-info');
alert.text(message);
alert.append(closeButton);
this.alerts.append(alert);
}
/**
* Requests all of the names represented by the buttons in the elements list.
* @param elements {Array<jQuery>} A list of the table row elements
* @param subsetFileName {String} If present then do a subsetting request instead.
*/
Atmos.prototype.request = function() {
//Pseudo globals.
var keyChain;
var certificateName;
var keyAdded = false;
return function(elements, subsetFilename) {
var names = [];
$(elements).find('.metaDataLink').each(function() {
var name = $(this).text();
names.push(name);
});
var subset = false;
if (!subsetFilename) {
$('#subsetting').hide();
} else {
$('#subsetting').show();
subset = true;
}
var scope = this;
//FIXME The following is temporary, it allows people to direct download from
//a single host with a small set of names. It is to demo the functionality but
//could use improvement. (Multiple servers, non static list, etc)
var directDls = $('#directDownloadList').empty();
names.forEach(function(name) {
if (scope.conversions[name]) {
//If the name exists in the conversions.
var ele = $('<a href="http://atmos-mwsc.ucar.edu/ucar/' + conversions[name] + '" class="list-group-item>' + name + '</a>');
directDls.append(ele);
}
});
this.requestForm.on('submit', function(e) {
//This will be registered for the next submit from the form.
e.preventDefault();
$('#request .alert').remove();
var variables = [];
if (subset) {
$('#subsetVariables .row').each(function() {
var t = $(this);
var values = {};
t.find('.values input').each(function() {
var t = $(this);
values[t.attr('name')] = t.val();
});
variables.push({
variable: t.find('.variable').val(),
values: values
});
});
}
//Form checking
var dest = scope.requestForm.find('#requestDest .active');
if (dest.length !== 1) {
var alert = $('<div class="alert alert-warning">A destination is required!' + closeButton + '<div>');
$('#request > .panel-body').append(alert);
return;
}
$('#request').modal('hide');
//Initial params are ok. We can close the form.
scope.cleanRequestForm();
$(this).off(e); //Don't fire this again, the request must be regenerated
//Key setup
if (!keyAdded) {
if (!scope.config.retrieval.demoKey || !scope.config.retrieval.demoKey.pub || !scope.config.retrieval.demoKey.priv) {
scope.createAlert("This host was not configured to handle retrieval! See console for details.", 'alert-danger');
console.error("Missing/invalid key! This must be configured in the config on the server.", scope.config.demoKey);
return;
}
//FIXME base64 may or may not exist in other browsers. Need a new polyfill.
var pub = new Buffer(base64.toByteArray(scope.config.retrieval.demoKey.pub));
//MUST be a Buffer (Buffer != Uint8Array)
var priv = new Buffer(base64.toByteArray(scope.config.retrieval.demoKey.priv));
var identityStorage = new MemoryIdentityStorage();
var privateKeyStorage = new MemoryPrivateKeyStorage();
keyChain = new KeyChain(new IdentityManager(identityStorage,privateKeyStorage),new SelfVerifyPolicyManager(identityStorage));
var keyName = new Name("/retrieve/DSK-123");
certificateName = keyName.getSubName(0, keyName.size() - 1).append("KEY").append(keyName.get(-1)).append("ID-CERT").append("0");
identityStorage.addKey(keyName, KeyType.RSA, new Blob(pub,false));
privateKeyStorage.setKeyPairForKeyName(keyName, KeyType.RSA, pub, priv);
scope.face.setCommandSigningInfo(keyChain, certificateName);
keyAdded = true;
}
//Retrieval
var retrievePrefix = new Name("/catalog/ui/" + guid());
scope.face.registerPrefix(retrievePrefix, function(prefix, interest, face, interestFilterId, filter) {
//On Interest
//This function will exist until the page exits but will likely only be used once.
var data = new Data(interest.getName());
var content;
if (subset) {
content = JSON.stringify({
name: subsetFilename,
subset: variables
});
} else {
content = JSON.stringify(names);
}
//Blob breaks the data! Don't use it
data.setContent(content);
//TODO Packetize this.
keyChain.sign(data, certificateName);
try {
face.putData(data);
console.log("Responded for", interest.getName().toUri(), data);
scope.createAlert("Data retrieval has initiated.", "alert-success");
} catch (e) {
console.error("Failed to respond to", interest.getName().toUri(), data);
scope.createAlert("Data retrieval failed.");
}
}, function(prefix) {
//On fail
scope.createAlert("Failed to register the retrieval URI! See console for details.", "alert-danger");
console.error("Failed to register URI:", prefix.toUri(), prefix);
}, function(prefix, registeredPrefixId) {
//On success
var name = new Name(dest.text());
name.append(prefix);
scope.expressInterest(name, function(interest, data) {
//Success
console.log("Request for", name.toUri(), "succeeded.", interest, data);
}, function() {
console.warn("Failed to request from retrieve agent.");
});
});
});
$('#request').modal();
//This forces the form to be the only option.
}
}();
Atmos.prototype.filterSetup = function() {
//Filter setup
var prefix = new Name(this.catalog).append("filters-initialization");
var scope = this;
this.getAll(prefix, function(data) {
//Success
var raw = JSON.parse(data.replace(/[\n\0]/g, ''));
//Remove null byte and parse
console.log("Filter categories:", raw);
$.each(raw, function(index, object) {
//Unpack list of objects
$.each(object, function(category, searchOptions) {
//Unpack category from object (We don't know what it is called)
//Create the category
var e = $('<li><a href="#">' + category.replace(/_/g, " ") + '</a><ul class="subnav nav nav-pills nav-stacked"></ul></li>');
var sub = e.find('ul.subnav');
$.each(searchOptions, function(index, name) {
//Create the filter list inside the category
var item = $('<li><a href="#">' + name + '</a></li>');
sub.append(item);
item.click(function() {
//Click on the side menu filters
if (item.hasClass('active')) {
//Does the filter already exist?
item.removeClass('active');
scope.filters.find(':contains(' + category + ':' + name + ')').remove();
} else {
//Add a filter
item.addClass('active');
var filter = $('<span class="label label-default"></span>');
filter.text(category + ':' + name);
scope.filters.append(filter);
filter.click(function() {
//Click on a filter
filter.remove();
item.removeClass('active');
});
}
});
});
//Toggle the menus. (Only respond when the immediate tab is clicked.)
e.find('> a').click(function() {
scope.categories.find('.subnav').slideUp();
var t = $(this).siblings('.subnav');
if (!t.is(':visible')) {
//If the sub menu is not visible
t.slideDown(function() {
t.triggerHandler('focus');
});
//Make it visible and look at it.
}
});
scope.categories.append(e);
});
});
}, function(interest) {
//Timeout
scope.createAlert("Failed to initialize the filters!", "alert-danger");
console.error("Failed to initialize filters!", interest);
ga('send', 'event', 'error', 'filters');
});
}
/**
* This function retrieves all segments in order until it knows it has reached the last one.
* It then returns the final joined result.
* @param prefix {String|Name} The ndn name we are retrieving.
* @param callback {function(String)} if successful, will call the callback with a string of data.
* @param failure {function(Interest)} if unsuccessful, will call failure with the last failed interest.
* @param stop {boolean} stop if no finalBlock.
*/
Atmos.prototype.getAll = function(prefix, callback, failure, stop) {
var scope = this;
var d = [];
var name = new Name(prefix);
var segment = 0;
var request = function() {
var n2 = new Name(name);
n2.appendSegment(segment);
scope.expressInterest(n2, handleData, function(err, interest) {
failure(interest)
});
//Forward to handleData and ignore error
}
var handleData = function(interest, data) {
d.push(data.getContent().toString());
var hasFinalBlock = data.getMetaInfo().getFinalBlockId().value.length === 0;
var finalBlockStop = hasFinalBlock && stop;
if (finalBlockStop || (!hasFinalBlock && interest.getName().get(-1).toSegment() == data.getMetaInfo().getFinalBlockId().toSegment())) {
callback(d.join(""));
} else {
segment++;
request();
}
}
request();
}
Atmos.prototype.cleanRequestForm = function() {
$('#requestDest').prev().removeClass('btn-success').addClass('btn-default');
$('#requestDropText').text('Destination');
$('#requestDest .active').removeClass('active');
$('#subsetMenu').attr('class', 'collapse');
$('#subsetVariables').empty();
$('#request .alert').alert('close').remove();
}
Atmos.prototype.setupRequestForm = function() {
var scope = this;
this.requestForm.find('#requestCancel').click(function() {
$('#request').unbind('submit')//Removes all event handlers.
.modal('hide');
//Hides the form.
scope.cleanRequestForm();
});
var dests = $(this.config['retrieval']['destinations'].reduce(function(prev, current) {
prev.push('<li><a href="#">');
prev.push(current);
prev.push("</a></li>");
return prev;
}, []).join(""));
this.requestForm.find('#requestDest').append(dests).on('click', 'a', function(e) {
$('#requestDest .active').removeClass('active');
var t = $(this);
t.parent().addClass('active');
$('#requestDropText').text(t.text());
$('#requestDest').prev().removeClass('btn-default').addClass('btn-success');
});
var addVariable = function(selector) {
var ele = $(selector).clone().attr('id', '');
ele.find('.close').click(function() {
ele.remove();
});
$('#subsetVariables').append(ele);
}
$('#subsetAddVariableBtn').click(function() {
addVariable('#customTemplate');
});
$('#subsetAddTimeVariable').click(function() {
addVariable('#timeTemplate');
});
$('#subsetAddLocVariable').click(function() {
addVariable('#locationTemplate');
});
}
Atmos.prototype.getMetaData = (function() {
var cache = {};
return function(element) {
var name = $(element).text();
ga('send', 'event', 'request', 'metaData');
var subsetButton = '<button class="btn btn-default subsetButton" type="button">Subset</button>';
if (cache[name]) {
return [subsetButton, '<pre class="metaData">', cache[name], '</pre>'].join('');
}
var prefix = new Name(name).append("metadata");
var id = guid();
//We need an id because the return MUST be a string.
var ret = '<div id="' + id + '"><span class="fa fa-spinner fa-spin"></span></div>';
this.getAll(prefix, function(data) {
var el = $('<pre class="metaData"></pre>');
el.text(data);
var container = $('<div></div>');
container.append($(subsetButton));
container.append(el);
$('#' + id).empty().append(container);
cache[name] = data;
}, function(interest) {
$('#' + id).text("The metadata is unavailable for this name.");
console.log("Data is unavailable for " + name);
});
return ret;
}
})();
return Atmos;
})();