diff --git a/js/classes/AsyncTask.js b/js/classes/AsyncTask.js new file mode 100644 index 00000000..5fb3e7d7 --- /dev/null +++ b/js/classes/AsyncTask.js @@ -0,0 +1,192 @@ +define([ + "underscore", + "core", + "utils", + "extensionMgr", + "config", + "libs/stacktrace", +], function(_, core, utils, extensionMgr) { + + var taskQueue = []; + + function AsyncTask() { + this.finished = false; + this.timeout = ASYNC_TASK_DEFAULT_TIMEOUT; + this.retryCounter = 0; + this.callPath = []; + this.runCallbacks = []; + this.successCallbacks = []; + this.errorCallbacks = []; + } + + /** + * onRun callbacks are called by chain(). These callbacks have to call + * chain() themselves to chain with next onRun callback or error() to + * throw an exception or retry() to restart the task. + */ + AsyncTask.prototype.onRun = function(callback) { + this.runCallbacks.push(callback); + }; + + /** + * onSuccess callbacks are called when every onRun callbacks have + * succeed. + */ + AsyncTask.prototype.onSuccess = function(callback) { + this.successCallbacks.push(callback); + }; + + /** + * onError callbacks are called when error() is called in a onRun + * callback. + */ + AsyncTask.prototype.onError = function(callback) { + this.errorCallbacks.push(callback); + }; + + /** + * chain() calls the next onRun callback or the onSuccess callbacks when + * finished. The optional callback parameter can be used to pass an + * onRun callback during execution, bypassing the onRun queue. + */ + AsyncTask.prototype.chain = function(callback) { + this.callPath.unshift(printStackTrace()[5]); + if(this.finished === true) { + return; + } + // If first execution + if(this.queue === undefined) { + // Create a copy of the onRun callbacks + this.queue = this.runCallbacks.slice(); + } + // If a callback is passed as a parameter + if(callback !== undefined) { + callback(); + return; + } + // If all callbacks have been run + if(this.queue.length === 0) { + // Run the onSuccess callbacks + runSafe(this, this.successCallbacks); + return; + } + // Run the next callback + var runCallback = this.queue.shift(); + runCallback(); + }; + + /** + * error() calls the onError callbacks passing the error parameter and + * ends the task by throwing an exception. + */ + AsyncTask.prototype.error = function(error) { + this.callPath.unshift(printStackTrace()[5]); + if(this.finished === true) { + return; + } + error = error || new Error("Unknown error|\n" + this.callPath.join("\n")); + if(error.message) { + extensionMgr.onError(error); + } + runSafe(this, this.errorCallbacks, error); + // Exit the current call stack + throw error; + }; + + /** + * retry() can be called in an onRun callback to restart the task + */ + AsyncTask.prototype.retry = function(error, maxRetryCounter) { + if(this.finished === true) { + return; + } + maxRetryCounter = maxRetryCounter || 5; + this.queue = undefined; + if(this.retryCounter >= maxRetryCounter) { + this.error(error); + return; + } + // Implement an exponential backoff + var delay = Math.pow(2, this.retryCounter++) * 1000; + currentTaskStartTime = utils.currentTime + delay; + currentTaskRunning = false; + this.callPath = []; + runTask(); + }; + + /** + * enqueue() has to be called to add the task in the running task queue + */ + AsyncTask.prototype.enqueue = function() { + taskQueue.push(this); + runTask(); + }; + + var asyncRunning = false; + var currentTask = undefined; + var currentTaskRunning = false; + var currentTaskStartTime = 0; + + // Run the next task in the queue if any and no other running + function runTask() { + // Use defer to avoid stack overflow + _.defer(function() { + + // If there is a task currently running + if(currentTaskRunning === true) { + // If the current task takes too long + if(currentTaskStartTime + currentTask.timeout < utils.currentTime) { + currentTask.error(new Error("A timeout occurred.|\n" + currentTask.callPath.join("\n"))); + } + return; + } + + if(currentTask === undefined) { + // If no task in the queue + if(taskQueue.length === 0) { + return; + } + + // Dequeue an enqueued task + currentTask = taskQueue.shift(); + currentTaskStartTime = utils.currentTime; + if(asyncRunning === false) { + asyncRunning = true; + extensionMgr.onAsyncRunning(true); + } + } + + // Run the task + if(currentTaskStartTime <= utils.currentTime) { + currentTaskRunning = true; + currentTask.chain(); + } + }); + } + // Run runTask function periodically + core.addPeriodicCallback(runTask); + + function runSafe(task, callbacks, param) { + try { + _.each(callbacks, function(callback) { + callback(param); + }); + } + finally { + task.finished = true; + if(currentTask === task) { + currentTask = undefined; + currentTaskRunning = false; + } + if(taskQueue.length === 0) { + asyncRunning = false; + extensionMgr.onAsyncRunning(false); + } + else { + runTask(); + } + } + } + + return AsyncTask; +}); \ No newline at end of file diff --git a/js/classes/FileDescriptor.js b/js/classes/FileDescriptor.js new file mode 100644 index 00000000..48d211ab --- /dev/null +++ b/js/classes/FileDescriptor.js @@ -0,0 +1,102 @@ +define(["utils"], function(utils) { + + function FileDescriptor(fileIndex, title, syncLocations, publishLocations) { + this.fileIndex = fileIndex; + this._title = title; + this._editorScrollTop = parseInt(localStorage[fileIndex + ".editorScrollTop"]) || 0; + this._editorStart = parseInt(localStorage[fileIndex + ".editorStart"]) || 0; + this._editorEnd = parseInt(localStorage[fileIndex + ".editorEnd"]) || 0; + this._previewScrollTop = parseInt(localStorage[fileIndex + ".previewScrollTop"]) || 0; + this._selectTime = parseInt(localStorage[fileIndex + ".selectTime"]) || 0; + this.syncLocations = syncLocations || {}; + this.publishLocations = publishLocations || {}; + Object.defineProperty(this, 'title', { + get: function() { + return this._title; + }, + set: function(title) { + this._title = title; + localStorage[this.fileIndex + ".title"] = title; + } + }); + Object.defineProperty(this, 'content', { + get: function() { + return localStorage[this.fileIndex + ".content"]; + }, + set: function(content) { + localStorage[this.fileIndex + ".content"] = content; + } + }); + Object.defineProperty(this, 'editorScrollTop', { + get: function() { + return this._editorScrollTop; + }, + set: function(editorScrollTop) { + this._editorScrollTop = editorScrollTop; + localStorage[this.fileIndex + ".editorScrollTop"] = editorScrollTop; + } + }); + Object.defineProperty(this, 'editorStart', { + get: function() { + return this._editorStart; + }, + set: function(editorStart) { + this._editorStart = editorStart; + localStorage[this.fileIndex + ".editorStart"] = editorStart; + } + }); + Object.defineProperty(this, 'editorEnd', { + get: function() { + return this._editorEnd; + }, + set: function(editorEnd) { + this._editorEnd = editorEnd; + localStorage[this.fileIndex + ".editorEnd"] = editorEnd; + } + }); + Object.defineProperty(this, 'previewScrollTop', { + get: function() { + return this._previewScrollTop; + }, + set: function(previewScrollTop) { + this._previewScrollTop = previewScrollTop; + localStorage[this.fileIndex + ".previewScrollTop"] = previewScrollTop; + } + }); + Object.defineProperty(this, 'selectTime', { + get: function() { + return this._selectTime; + }, + set: function(selectTime) { + this._selectTime = selectTime; + localStorage[this.fileIndex + ".selectTime"] = selectTime; + } + }); + } + + FileDescriptor.prototype.addSyncLocation = function(syncAttributes) { + utils.storeAttributes(syncAttributes); + utils.appendIndexToArray(this.fileIndex + ".sync", syncAttributes.syncIndex); + this.syncLocations[syncAttributes.syncIndex] = syncAttributes; + }; + + FileDescriptor.prototype.removeSyncLocation = function(syncAttributes) { + utils.removeIndexFromArray(this.fileIndex + ".sync", syncAttributes.syncIndex); + delete this.syncLocations[syncAttributes.syncIndex]; + localStorage.removeItem(syncAttributes.syncIndex); + }; + + FileDescriptor.prototype.addPublishLocation = function(publishAttributes) { + utils.storeAttributes(publishAttributes); + utils.appendIndexToArray(this.fileIndex + ".publish", publishAttributes.publishIndex); + this.publishLocations[publishAttributes.publishIndex] = publishAttributes; + }; + + FileDescriptor.prototype.removePublishLocation = function(publishAttributes) { + utils.removeIndexFromArray(this.fileIndex + ".publish", publishAttributes.publishIndex); + delete this.publishLocations[publishAttributes.publishIndex]; + localStorage.removeItem(publishAttributes.publishIndex); + }; + + return FileDescriptor; +}); \ No newline at end of file diff --git a/js/extensions/dialogOpenHarddrive.js b/js/extensions/dialogOpenHarddrive.js new file mode 100644 index 00000000..8a3e3b54 --- /dev/null +++ b/js/extensions/dialogOpenHarddrive.js @@ -0,0 +1,94 @@ +define([ + "jquery", + "underscore", + "toMarkdown", + "config", +], function($, _, toMarkdown) { + + var dialogOpenHarddrive = { + extensionId: "dialogOpenHarddrive", + extensionName: 'Dialog "Open from"', + settingsBloc: '

Handles the "Open from hard drive" and the "Convert HTML to Markdown" dialog boxes.

' + }; + + var fileMgr = undefined; + dialogOpenHarddrive.onFileMgrCreated = function(fileMgrParameter) { + fileMgr = fileMgrParameter; + }; + + var extensionMgr = undefined; + dialogOpenHarddrive.onExtensionMgrCreated = function(extensionMgrParameter) { + extensionMgr = extensionMgrParameter; + }; + + var contentWrapper = undefined; + var converter = undefined; + var htmlContentWrapper = function(content) { + return converter.makeMd(content); + }; + function handleFileImport(evt) { + evt.stopPropagation(); + evt.preventDefault(); + var files = (evt.dataTransfer || evt.target).files; + $("#modal-import-harddrive-markdown, #modal-import-harddrive-html").modal("hide"); + _.each(files, function(file) { + var reader = new FileReader(); + reader.onload = (function(importedFile) { + return function(e) { + var content = e.target.result; + if(content.match(/\uFFFD/)) { + extensionMgr.onError(importedFile.name + " is a binary file."); + return; + } + content = contentWrapper ? contentWrapper(content) : content; + if(content === undefined) { + extensionMgr.onError(importedFile.name + " is not a valid HTML file."); + return; + } + var title = importedFile.name; + var dotPosition = title.lastIndexOf("."); + title = dotPosition !== -1 ? title.substring(0, dotPosition) : title; + var fileDesc = fileMgr.createFile(title, content); + fileMgr.selectFile(fileDesc); + }; + })(file); + var blob = file.slice(0, IMPORT_FILE_MAX_CONTENT_SIZE); + reader.readAsText(blob); + }); + } + + function handleMarkdownImport(evt) { + contentWrapper = undefined; + handleFileImport(evt); + } + + function handleHtmlImport(evt) { + contentWrapper = htmlContentWrapper; + handleFileImport(evt); + } + + function handleDragOver(evt) { + evt.stopPropagation(); + evt.preventDefault(); + evt.dataTransfer.dropEffect = 'copy'; + } + + dialogOpenHarddrive.onReady = function() { + // Create toMarkdown converter + converter = new toMarkdown.converter(); + + $("#input-file-import-harddrive-markdown").change(handleMarkdownImport); + $('#dropzone-import-harddrive-markdown').each(function() { + this.addEventListener('dragover', handleDragOver, false); + this.addEventListener('drop', handleMarkdownImport, false); + }); + $("#input-file-import-harddrive-html").change(handleHtmlImport); + $('#dropzone-import-harddrive-html').each(function() { + this.addEventListener('dragover', handleDragOver, false); + this.addEventListener('drop', handleHtmlImport, false); + }); + }; + + return dialogOpenHarddrive; + +}); \ No newline at end of file diff --git a/js/libs/to-markdown.js b/js/libs/to-markdown.js new file mode 100644 index 00000000..ec16ed27 --- /dev/null +++ b/js/libs/to-markdown.js @@ -0,0 +1,248 @@ +/* + * to-markdown - an HTML to Markdown converter + * + * Copyright 2011-2012, Dom Christie + * Licenced under the MIT licence + * + */ + +(function() { + + var root = this; + var toMarkdown = {}; + var isNode = false; + + if(typeof module !== 'undefined' && module.exports) { + module.exports = toMarkdown; + root.toMarkdown = toMarkdown; + isNode = true; + } + else { + root.toMarkdown = toMarkdown; + } + + toMarkdown.converter = function(options) { + + if(options && options.elements && $.isArray(options.elements)) { + ELEMENTS = ELEMENTS.concat(options.elements); + } + + this.makeMd = function(input, callback) { + var result; + if(isNode) { + var jsdom = require('jsdom'); + jsdom.env({ + html: input, + scripts: [ + 'http://code.jquery.com/jquery-1.6.4.min.js' + ], + done: function(errors, window) { + if(typeof callback === 'function') { + callback(process(input, window.$)); + } + } + }); + } + else { + result = process(input, $); + } + return result; + }; + }; + + var process = function(input, $) { + // Escape potential ol triggers + // see bottom of lists section: http://daringfireball.net/projects/markdown/syntax#list + input = input.replace(/(\d+)\. /g, '$1\\. '); + + // Wrap in containing div + var $container = $('
'); + var $input = $container.html(input); + + // Remove whitespace + $input.find('*:not(pre, code)').contents().filter(function() { + return this.nodeType === 3 && (/^\s+$/.test(this.nodeValue)); + }).remove(); + + var selectors = []; + for(var i = 0, len = ELEMENTS.length; i < len; i++) { + selectors.push(ELEMENTS[i].selector); + } + selectors = selectors.join(','); + + while($input.find(selectors).length) { + for(var i = 0, len = ELEMENTS.length; i < len; i++) { + + // Find the innermost elements containing NO children that convert to markdown + $matches = $input.find(ELEMENTS[i].selector + ':not(:has("' + selectors + '"))'); + + $matches.each(function(j, el) { + var $el = $(el); + $el.before(ELEMENTS[i].replacement($el.html(), $el)).remove(); + }); + } + } + return cleanUp($input.html()); + }; + + // ============= + // = Utilities = + // ============= + + var trimNewLines = function(str) { + return str.replace(/^[\n\r\f]+|[\n\r\f]+$/g, ''); + }; + + var decodeHtmlEntities = function(str) { + return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + }; + + var cleanUp = function(string) { + string = string.replace(/^[\t\r\n]+|[\t\r\n]+$/g, ''); // trim leading/trailing whitespace + string = string.replace(/\n\s+\n/g, '\n\n'); + string = string.replace(/\n{3,}/g, '\n\n'); // limit consecutive linebreaks to 2 + string = decodeHtmlEntities(string); + return string; + }; + + var strongReplacement = function(innerHTML) { + innerHTML = trimNewLines(innerHTML); + return innerHTML ? '**' + innerHTML + '**' : ''; + }; + var emReplacement = function(innerHTML) { + innerHTML = trimNewLines(innerHTML); + return innerHTML ? '_' + innerHTML + '_' : ''; + }; + + // ============ + // = Elements = + // ============ + + var ELEMENTS = [ + { + selector: 'p', + replacement: function(innerHTML, el) { + innerHTML = $.trim(innerHTML); + return innerHTML ? '\n\n' + innerHTML + '\n\n' : ''; + } + }, + { + selector: 'br', + replacement: function(innerHTML, el) { + return '\n'; + } + }, + { + selector: 'h1,h2,h3,h4,h5,h6', + replacement: function(innerHTML, $el) { + innerHTML = $.trim(innerHTML); + var hLevel = $el.prop("nodeName").charAt(1), + prefix = ''; + for(var i = 0; i < hLevel; i++) { + prefix += '#'; + } + return innerHTML ? '\n\n' + prefix + ' ' + innerHTML + '\n\n' : ''; + } + }, + { + selector: 'hr', + replacement: function(innerHTML, el) { + return '\n\n* * *\n\n'; + } + }, + { + selector: 'a[href]', + replacement: function(innerHTML, $el) { + if(innerHTML) { + innerHTML = trimNewLines(innerHTML); + var href = $el.attr('href'), + title = $el.attr('title') || ''; + return '[' + innerHTML + ']' + '(' + href + (title ? ' "' + title + '"' : '') + ')'; + } + else { + return false; + } + } + }, + { + selector: 'b', + replacement: strongReplacement + }, + { + selector: 'strong', + replacement: strongReplacement + }, + { + selector: 'i', + replacement: emReplacement + }, + { + selector: 'em', + replacement: emReplacement + }, + { + selector: 'code', + replacement: function(innerHTML, el) { + innerHTML = trimNewLines(innerHTML); + return innerHTML ? '`' + innerHTML + '`' : ''; + } + }, + { + selector: 'img', + replacement: function(innerHTML, $el) { + var alt = $el.attr('alt') || '', + src = $el.attr('src') || '', + title = $el.attr('title') || ''; + return '![' + alt + ']' + '(' + src + (title ? ' "' + title + '"' : '') + ')'; + } + }, + { + selector: 'pre', + replacement: function(innerHTML, el) { + if(/^\s*\`/.test(innerHTML)) { + innerHTML = innerHTML.replace(/\`/g, ''); + return ' ' + innerHTML.replace(/\n/g, '\n '); + } + else { + return ''; + } + } + }, + { + selector: 'li', + replacement: function(innerHTML, $el) { + innerHTML = innerHTML.replace(/^\s+|\s+$/, '').replace(/\n/gm, '\n '); + var prefix = '* '; + var suffix = ''; + var $parent = $el.parent(); + var $children = $parent.contents().filter(function() { + return (this.nodeType === 1 && this.nodeName === 'LI') || (this.nodeType === 3); + }); + var index = $children.index($el) + 1; + + prefix = $parent.is('ol') ? index + '. ' : '* '; + if(index == $children.length) { + if(!$el.parents('li').length) { + suffix = '\n'; + } + innerHTML = innerHTML.replace(/\s+$/, ''); // Trim + $el.unwrap(); + } + return prefix + innerHTML + suffix + '\n'; + } + }, + { + selector: 'blockquote', + replacement: function(innerHTML, el) { + innerHTML = innerHTML = $.trim(innerHTML).replace(/\n{3,}/g, '\n\n'); + innerHTML = innerHTML.replace(/\n/g, '\n> '); + return "> " + innerHTML; + } + } + ]; + + var NON_MD_BLOCK_ELEMENTS = ['address', 'article', 'aside', 'audio', 'canvas', 'div', 'dl', 'dd', 'dt', + 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'output', + 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'section', 'video']; + +})();