Support export to and import from Google Drive

This commit is contained in:
Cheng-Han, Wu 2016-03-04 23:17:35 +08:00
parent c183002c14
commit 845ef9bad6
8 changed files with 528 additions and 5 deletions

View file

@ -329,6 +329,8 @@ function actionDownload(req, res, noteId) {
filename = encodeURIComponent(filename); filename = encodeURIComponent(filename);
res.writeHead(200, { res.writeHead(200, {
'Access-Control-Allow-Origin': '*', //allow CORS as API 'Access-Control-Allow-Origin': '*', //allow CORS as API
'Access-Control-Allow-Headers': 'Range',
'Access-Control-Expose-Headers': 'Cache-Control, Content-Encoding, Content-Range',
'Content-Type': 'text/markdown; charset=UTF-8', 'Content-Type': 'text/markdown; charset=UTF-8',
'Cache-Control': 'private', 'Cache-Control': 'private',
'Content-disposition': 'attachment; filename=' + filename + '.md', 'Content-disposition': 'attachment; filename=' + filename + '.md',

View file

@ -2,6 +2,9 @@
var domain = 'change this'; // domain name var domain = 'change this'; // domain name
var urlpath = ''; // sub url path, like: www.example.com/<urlpath> var urlpath = ''; // sub url path, like: www.example.com/<urlpath>
var GOOGLE_API_KEY = 'change this';
var GOOGLE_CLIENT_ID = 'change this';
var port = window.location.port; var port = window.location.port;
var serverurl = window.location.protocol + '//' + domain + (port ? ':' + port : '') + (urlpath ? '/' + urlpath : ''); var serverurl = window.location.protocol + '//' + domain + (port ? ':' + port : '') + (urlpath ? '/' + urlpath : '');
var noteid = urlpath ? window.location.pathname.slice(urlpath.length + 1, window.location.pathname.length).split('/')[1] : window.location.pathname.split('/')[1]; var noteid = urlpath ? window.location.pathname.slice(urlpath.length + 1, window.location.pathname.length).split('/')[1] : window.location.pathname.split('/')[1];

View file

@ -0,0 +1,118 @@
/**!
* Google Drive File Picker Example
* By Daniel Lo Nigro (http://dan.cx/)
*/
(function() {
/**
* Initialise a Google Driver file picker
*/
var FilePicker = window.FilePicker = function(options) {
// Config
this.apiKey = options.apiKey;
this.clientId = options.clientId;
// Elements
this.buttonEl = options.buttonEl;
// Events
this.onSelect = options.onSelect;
this.buttonEl.on('click', this.open.bind(this));
// Disable the button until the API loads, as it won't work properly until then.
this.buttonEl.prop('disabled', true);
// Load the drive API
gapi.client.setApiKey(this.apiKey);
gapi.client.load('drive', 'v2', this._driveApiLoaded.bind(this));
google.load('picker', '1', { callback: this._pickerApiLoaded.bind(this) });
}
FilePicker.prototype = {
/**
* Open the file picker.
*/
open: function() {
// Check if the user has already authenticated
var token = gapi.auth.getToken();
if (token) {
this._showPicker();
} else {
// The user has not yet authenticated with Google
// We need to do the authentication before displaying the Drive picker.
this._doAuth(false, function() { this._showPicker(); }.bind(this));
}
},
/**
* Show the file picker once authentication has been done.
* @private
*/
_showPicker: function() {
var accessToken = gapi.auth.getToken().access_token;
var view = new google.picker.DocsView();
view.setMimeTypes("text/markdown,text/html");
view.setIncludeFolders(true);
this.picker = new google.picker.PickerBuilder().
enableFeature(google.picker.Feature.NAV_HIDDEN).
addView(view).
setAppId(this.clientId).
setOAuthToken(accessToken).
setCallback(this._pickerCallback.bind(this)).
build().
setVisible(true);
},
/**
* Called when a file has been selected in the Google Drive file picker.
* @private
*/
_pickerCallback: function(data) {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
var file = data[google.picker.Response.DOCUMENTS][0],
id = file[google.picker.Document.ID],
request = gapi.client.drive.files.get({
fileId: id
});
request.execute(this._fileGetCallback.bind(this));
}
},
/**
* Called when file details have been retrieved from Google Drive.
* @private
*/
_fileGetCallback: function(file) {
if (this.onSelect) {
this.onSelect(file);
}
},
/**
* Called when the Google Drive file picker API has finished loading.
* @private
*/
_pickerApiLoaded: function() {
this.buttonEl.prop('disabled', false);
},
/**
* Called when the Google Drive API has finished loading.
* @private
*/
_driveApiLoaded: function() {
this._doAuth(true);
},
/**
* Authenticate with Google Drive via the Google JavaScript API.
* @private
*/
_doAuth: function(immediate, callback) {
gapi.auth.authorize({
client_id: this.clientId,
scope: 'https://www.googleapis.com/auth/drive.readonly',
immediate: immediate
}, callback ? callback : function() {});
}
};
}());

View file

@ -0,0 +1,269 @@
/**
* Helper for implementing retries with backoff. Initial retry
* delay is 1 second, increasing by 2x (+jitter) for subsequent retries
*
* @constructor
*/
var RetryHandler = function() {
this.interval = 1000; // Start at one second
this.maxInterval = 60 * 1000; // Don't wait longer than a minute
};
/**
* Invoke the function after waiting
*
* @param {function} fn Function to invoke
*/
RetryHandler.prototype.retry = function(fn) {
setTimeout(fn, this.interval);
this.interval = this.nextInterval_();
};
/**
* Reset the counter (e.g. after successful request.)
*/
RetryHandler.prototype.reset = function() {
this.interval = 1000;
};
/**
* Calculate the next wait time.
* @return {number} Next wait interval, in milliseconds
*
* @private
*/
RetryHandler.prototype.nextInterval_ = function() {
var interval = this.interval * 2 + this.getRandomInt_(0, 1000);
return Math.min(interval, this.maxInterval);
};
/**
* Get a random int in the range of min to max. Used to add jitter to wait times.
*
* @param {number} min Lower bounds
* @param {number} max Upper bounds
* @private
*/
RetryHandler.prototype.getRandomInt_ = function(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
};
/**
* Helper class for resumable uploads using XHR/CORS. Can upload any Blob-like item, whether
* files or in-memory constructs.
*
* @example
* var content = new Blob(["Hello world"], {"type": "text/plain"});
* var uploader = new MediaUploader({
* file: content,
* token: accessToken,
* onComplete: function(data) { ... }
* onError: function(data) { ... }
* });
* uploader.upload();
*
* @constructor
* @param {object} options Hash of options
* @param {string} options.token Access token
* @param {blob} options.file Blob-like item to upload
* @param {string} [options.fileId] ID of file if replacing
* @param {object} [options.params] Additional query parameters
* @param {string} [options.contentType] Content-type, if overriding the type of the blob.
* @param {object} [options.metadata] File metadata
* @param {function} [options.onComplete] Callback for when upload is complete
* @param {function} [options.onProgress] Callback for status for the in-progress upload
* @param {function} [options.onError] Callback if upload fails
*/
var MediaUploader = function(options) {
var noop = function() {};
this.file = options.file;
this.contentType = options.contentType || this.file.type || 'application/octet-stream';
this.metadata = options.metadata || {
'title': this.file.name,
'mimeType': this.contentType
};
this.token = options.token;
this.onComplete = options.onComplete || noop;
this.onProgress = options.onProgress || noop;
this.onError = options.onError || noop;
this.offset = options.offset || 0;
this.chunkSize = options.chunkSize || 0;
this.retryHandler = new RetryHandler();
this.url = options.url;
if (!this.url) {
var params = options.params || {};
params.uploadType = 'resumable';
this.url = this.buildUrl_(options.fileId, params, options.baseUrl);
}
this.httpMethod = options.fileId ? 'PUT' : 'POST';
};
/**
* Initiate the upload.
*/
MediaUploader.prototype.upload = function() {
var self = this;
var xhr = new XMLHttpRequest();
xhr.open(this.httpMethod, this.url, true);
xhr.setRequestHeader('Authorization', 'Bearer ' + this.token);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('X-Upload-Content-Length', this.file.size);
xhr.setRequestHeader('X-Upload-Content-Type', this.contentType);
xhr.onload = function(e) {
if (e.target.status < 400) {
var location = e.target.getResponseHeader('Location');
this.url = location;
this.sendFile_();
} else {
this.onUploadError_(e);
}
}.bind(this);
xhr.onerror = this.onUploadError_.bind(this);
xhr.send(JSON.stringify(this.metadata));
};
/**
* Send the actual file content.
*
* @private
*/
MediaUploader.prototype.sendFile_ = function() {
var content = this.file;
var end = this.file.size;
if (this.offset || this.chunkSize) {
// Only bother to slice the file if we're either resuming or uploading in chunks
if (this.chunkSize) {
end = Math.min(this.offset + this.chunkSize, this.file.size);
}
content = content.slice(this.offset, end);
}
var xhr = new XMLHttpRequest();
xhr.open('PUT', this.url, true);
xhr.setRequestHeader('Content-Type', this.contentType);
xhr.setRequestHeader('Content-Range', "bytes " + this.offset + "-" + (end - 1) + "/" + this.file.size);
xhr.setRequestHeader('X-Upload-Content-Type', this.file.type);
if (xhr.upload) {
xhr.upload.addEventListener('progress', this.onProgress);
}
xhr.onload = this.onContentUploadSuccess_.bind(this);
xhr.onerror = this.onContentUploadError_.bind(this);
xhr.send(content);
};
/**
* Query for the state of the file for resumption.
*
* @private
*/
MediaUploader.prototype.resume_ = function() {
var xhr = new XMLHttpRequest();
xhr.open('PUT', this.url, true);
xhr.setRequestHeader('Content-Range', "bytes */" + this.file.size);
xhr.setRequestHeader('X-Upload-Content-Type', this.file.type);
if (xhr.upload) {
xhr.upload.addEventListener('progress', this.onProgress);
}
xhr.onload = this.onContentUploadSuccess_.bind(this);
xhr.onerror = this.onContentUploadError_.bind(this);
xhr.send();
};
/**
* Extract the last saved range if available in the request.
*
* @param {XMLHttpRequest} xhr Request object
*/
MediaUploader.prototype.extractRange_ = function(xhr) {
var range = xhr.getResponseHeader('Range');
if (range) {
this.offset = parseInt(range.match(/\d+/g).pop(), 10) + 1;
}
};
/**
* Handle successful responses for uploads. Depending on the context,
* may continue with uploading the next chunk of the file or, if complete,
* invokes the caller's callback.
*
* @private
* @param {object} e XHR event
*/
MediaUploader.prototype.onContentUploadSuccess_ = function(e) {
if (e.target.status == 200 || e.target.status == 201) {
this.onComplete(e.target.response);
} else if (e.target.status == 308) {
this.extractRange_(e.target);
this.retryHandler.reset();
this.sendFile_();
} else {
this.onContentUploadError_(e);
}
};
/**
* Handles errors for uploads. Either retries or aborts depending
* on the error.
*
* @private
* @param {object} e XHR event
*/
MediaUploader.prototype.onContentUploadError_ = function(e) {
if (e.target.status && e.target.status < 500) {
this.onError(e.target.response);
} else {
this.retryHandler.retry(this.resume_.bind(this));
}
};
/**
* Handles errors for the initial request.
*
* @private
* @param {object} e XHR event
*/
MediaUploader.prototype.onUploadError_ = function(e) {
this.onError(e.target.response); // TODO - Retries for initial upload
};
/**
* Construct a query string from a hash/object
*
* @private
* @param {object} [params] Key/value pairs for query string
* @return {string} query string
*/
MediaUploader.prototype.buildQuery_ = function(params) {
params = params || {};
return Object.keys(params).map(function(key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
}).join('&');
};
/**
* Build the drive upload URL
*
* @private
* @param {string} [id] File ID if replacing
* @param {object} [params] Query parameters
* @return {string} URL
*/
MediaUploader.prototype.buildUrl_ = function(id, params, baseUrl) {
var url = baseUrl || 'https://www.googleapis.com/upload/drive/v2/files/';
if (id) {
url += id;
}
var query = this.buildQuery_(params);
if (query) {
url += '?' + query;
}
return url;
};

View file

@ -486,10 +486,12 @@ var ui = {
}, },
export: { export: {
dropbox: $(".ui-save-dropbox"), dropbox: $(".ui-save-dropbox"),
googleDrive: $(".ui-save-google-drive"),
gist: $(".ui-save-gist") gist: $(".ui-save-gist")
}, },
import: { import: {
dropbox: $(".ui-import-dropbox"), dropbox: $(".ui-import-dropbox"),
googleDrive: $(".ui-import-google-drive"),
clipboard: $(".ui-import-clipboard") clipboard: $(".ui-import-clipboard")
}, },
beta: { beta: {
@ -994,6 +996,22 @@ function closestIndex(arr, closestTo) {
return index; // return the value return index; // return the value
} }
function showMessageModal(title, header, href, text, success) {
var modal = $('.message-modal');
modal.find('.modal-title').html(title);
modal.find('.modal-body h5').html(header);
if (href)
modal.find('.modal-body a').attr('href', href).text(text);
else
modal.find('.modal-body a').removeAttr('href').text(text);
modal.find('.modal-footer button').removeClass('btn-default btn-success btn-danger')
if (success)
modal.find('.modal-footer button').addClass('btn-success');
else
modal.find('.modal-footer button').addClass('btn-danger');
modal.modal('show');
}
//button actions //button actions
//share //share
ui.toolbar.publish.attr("href", noteurl + "/publish"); ui.toolbar.publish.attr("href", noteurl + "/publish");
@ -1031,6 +1049,53 @@ ui.toolbar.export.dropbox.click(function () {
}; };
Dropbox.save(options); Dropbox.save(options);
}); });
function uploadToGoogleDrive(accessToken) {
ui.spinner.show();
var filename = renderFilename(ui.area.markdown) + '.md';
var markdown = editor.getValue();
var blob = new Blob([markdown], {
type: "text/markdown;charset=utf-8"
});
blob.name = filename;
var uploader = new MediaUploader({
file: blob,
token: accessToken,
onComplete: function(data) {
data = JSON.parse(data);
showMessageModal('<i class="fa fa-cloud-upload"></i> Export to Google Drive', 'Export Complete!', data.alternateLink, 'Click here to view your file', true);
ui.spinner.hide();
},
onError: function(data) {
var modal = $('.export-modal');
showMessageModal('<i class="fa fa-cloud-upload"></i> Export to Google Drive', 'Export Error :(', '', data, false);
ui.spinner.hide();
}
});
uploader.upload();
}
function googleApiAuth(immediate, callback) {
gapi.auth.authorize(
{
'client_id': GOOGLE_CLIENT_ID,
'scope': 'https://www.googleapis.com/auth/drive.file',
'immediate': immediate
}, callback ? callback : function() {});
}
function onGoogleClientLoaded() {
googleApiAuth(true);
buildImportFromGoogleDrive();
}
// export to google drive
ui.toolbar.export.googleDrive.click(function (e) {
var token = gapi.auth.getToken();
if (token) {
uploadToGoogleDrive(token.access_token);
} else {
googleApiAuth(false, function(result) {
uploadToGoogleDrive(result.access_token);
});
}
});
//export to gist //export to gist
ui.toolbar.export.gist.attr("href", noteurl + "/gist"); ui.toolbar.export.gist.attr("href", noteurl + "/gist");
//import from dropbox //import from dropbox
@ -1047,6 +1112,41 @@ ui.toolbar.import.dropbox.click(function () {
}; };
Dropbox.choose(options); Dropbox.choose(options);
}); });
// import from google drive
var picker = null;
function buildImportFromGoogleDrive() {
picker = new FilePicker({
apiKey: GOOGLE_API_KEY,
clientId: GOOGLE_CLIENT_ID,
buttonEl: ui.toolbar.import.googleDrive,
onSelect: function(file) {
if (file.downloadUrl) {
ui.spinner.show();
var accessToken = gapi.auth.getToken().access_token;
$.ajax({
type: 'GET',
beforeSend: function (request)
{
request.setRequestHeader('Authorization', 'Bearer ' + accessToken);
},
url: file.downloadUrl,
success: function(data) {
if (file.fileExtension == 'html')
parseToEditor(data);
else
replaceAll(data);
},
error: function (data) {
showMessageModal('<i class="fa fa-cloud-download"></i> Import from Google Drive', 'Import failed :(', '', data, false);
},
complete: function () {
ui.spinner.hide();
}
});
}
}
});
}
//import from clipboard //import from clipboard
ui.toolbar.import.clipboard.click(function () { ui.toolbar.import.clipboard.click(function () {
//na //na
@ -1188,7 +1288,7 @@ function importFromUrl(url) {
//console.log(url); //console.log(url);
if (url == null) return; if (url == null) return;
if (!isValidURL(url)) { if (!isValidURL(url)) {
alert('Not valid URL :('); showMessageModal('<i class="fa fa-cloud-download"></i> Import from URL', 'Not valid URL :(', '', '', false);
return; return;
} }
$.ajax({ $.ajax({
@ -1201,8 +1301,8 @@ function importFromUrl(url) {
else else
replaceAll(data); replaceAll(data);
}, },
error: function () { error: function (data) {
alert('Import failed :('); showMessageModal('<i class="fa fa-cloud-download"></i> Import from URL', 'Import failed :(', '', data, false);
}, },
complete: function () { complete: function () {
ui.spinner.hide(); ui.spinner.hide();

View file

@ -136,3 +136,22 @@
</div> </div>
</div> </div>
</div> </div>
<!-- message modal -->
<div class="modal fade message-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="myModalLabel"></h4>
</div>
<div class="modal-body" style="color:black;">
<h5></h5>
<a target="_blank"></a>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>

View file

@ -61,7 +61,10 @@
<script src="<%- url %>/vendor/md-toc.js" defer></script> <script src="<%- url %>/vendor/md-toc.js" defer></script>
<script src="<%- url %>/vendor/showup/showup.js" defer></script> <script src="<%- url %>/vendor/showup/showup.js" defer></script>
<script src="<%- url %>/vendor/randomColor.js" defer></script> <script src="<%- url %>/vendor/randomColor.js" defer></script>
<script type="text/javascript" src="https://www.dropbox.com/static/api/2/dropins.js" id="dropboxjs" data-app-key="rdoizrlnkuha23r" async defer></script> <script type="text/javascript" src="https://www.dropbox.com/static/api/2/dropins.js" id="dropboxjs" data-app-key="change this" async defer></script>
<script src="https://www.google.com/jsapi" defer></script>
<script src="<%- url %>/js/google-drive-upload.js" defer></script>
<script src="<%- url %>/js/google-drive-picker.js" defer></script>
<script type="text/x-mathjax-config"> <script type="text/x-mathjax-config">
MathJax.Hub.Config({ messageStyle: "none", skipStartupTypeset: true ,tex2jax: {inlineMath: [['$','$'], ['\\(','\\)']], processEscapes: true }}); MathJax.Hub.Config({ messageStyle: "none", skipStartupTypeset: true ,tex2jax: {inlineMath: [['$','$'], ['\\(','\\)']], processEscapes: true }});
</script> </script>
@ -72,3 +75,4 @@
<script src="<%- url %>/js/history.js" defer></script> <script src="<%- url %>/js/history.js" defer></script>
<script src="<%- url %>/js/index.js" defer></script> <script src="<%- url %>/js/index.js" defer></script>
<script src="<%- url %>/js/syncscroll.js" defer></script> <script src="<%- url %>/js/syncscroll.js" defer></script>
<script src="https://apis.google.com/js/client:plusone.js?onload=onGoogleClientLoaded" defer></script>

View file

@ -36,12 +36,16 @@
<li class="dropdown-header">Export</li> <li class="dropdown-header">Export</li>
<li role="presentation"><a role="menuitem" class="ui-save-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a> <li role="presentation"><a role="menuitem" class="ui-save-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a>
</li> </li>
<li role="presentation"><a role="menuitem" class="ui-save-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-upload fa-fw"></i> Google Drive</a>
</li>
<li role="presentation"><a role="menuitem" class="ui-save-gist" tabindex="-1" href="#" target="_blank"><i class="fa fa-github fa-fw"></i> Gist</a> <li role="presentation"><a role="menuitem" class="ui-save-gist" tabindex="-1" href="#" target="_blank"><i class="fa fa-github fa-fw"></i> Gist</a>
</li> </li>
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Import</li> <li class="dropdown-header">Import</li>
<li role="presentation"><a role="menuitem" class="ui-import-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a> <li role="presentation"><a role="menuitem" class="ui-import-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a>
</li> </li>
<li role="presentation"><a role="menuitem" class="ui-import-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-download fa-fw"></i> Google Drive</a>
</li>
<li role="presentation"><a role="menuitem" class="ui-import-clipboard" href="#" data-toggle="modal" data-target="#clipboardModal"><i class="fa fa-clipboard fa-fw"></i> Clipboard</a> <li role="presentation"><a role="menuitem" class="ui-import-clipboard" href="#" data-toggle="modal" data-target="#clipboardModal"><i class="fa fa-clipboard fa-fw"></i> Clipboard</a>
</li> </li>
<li class="divider"></li> <li class="divider"></li>
@ -113,12 +117,16 @@
<li class="dropdown-header">Export</li> <li class="dropdown-header">Export</li>
<li role="presentation"><a role="menuitem" class="ui-save-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a> <li role="presentation"><a role="menuitem" class="ui-save-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a>
</li> </li>
<li role="presentation"><a role="menuitem" class="ui-save-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-upload fa-fw"></i> Google Drive</a>
</li>
<li role="presentation"><a role="menuitem" class="ui-save-gist" tabindex="-1" href="#" target="_blank"><i class="fa fa-github fa-fw"></i> Gist</a> <li role="presentation"><a role="menuitem" class="ui-save-gist" tabindex="-1" href="#" target="_blank"><i class="fa fa-github fa-fw"></i> Gist</a>
</li> </li>
<li class="divider"></li> <li class="divider"></li>
<li class="dropdown-header">Import</li> <li class="dropdown-header">Import</li>
<li role="presentation"><a role="menuitem" class="ui-import-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a> <li role="presentation"><a role="menuitem" class="ui-import-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a>
</li> </li>
<li role="presentation"><a role="menuitem" class="ui-import-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-download fa-fw"></i> Google Drive</a>
</li>
<li role="presentation"><a role="menuitem" class="ui-import-clipboard" href="#" data-toggle="modal" data-target="#clipboardModal"><i class="fa fa-clipboard fa-fw"></i> Clipboard</a> <li role="presentation"><a role="menuitem" class="ui-import-clipboard" href="#" data-toggle="modal" data-target="#clipboardModal"><i class="fa fa-clipboard fa-fw"></i> Clipboard</a>
</li> </li>
<li class="divider"></li> <li class="divider"></li>