diff --git a/gapps.config.json b/gapps.config.json index 01739da..77f1a73 100644 --- a/gapps.config.json +++ b/gapps.config.json @@ -1,4 +1,4 @@ { "path": "build/gas", - "fileId": "1GJIHQGKlDsooooj5FMmi_E4yNvrUCuR1GK0l-pH8JwR-1G7lbX6rXiyp" + "fileId": "1ezetI0_2GJaRnPrz5wR0QZLTI3O-PIvX-SBjFsNW2usB3EzL8alhdLfp" } diff --git a/gulpfile.js b/gulpfile.js index 73ee942..79bf494 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -3,9 +3,17 @@ var browserify = require('browserify'); var gjslint = require('gulp-gjslint'); var gulp = require('gulp'); var gutil = require('gulp-util'); -var source = require('vinyl-source-stream'); var htmlProcessor = require('gulp-htmlprocessor'); +var open = require('gulp-open'); +var os = require('os'); var sass = require('gulp-sass'); +var source = require('vinyl-source-stream'); +var stringify = require('stringify'); +var del = require('del'); + +// Used for our new bundle system +var rename = require('gulp-rename'); +var es = require('event-stream'); // Node modules var exec = require('child_process').exec; @@ -20,29 +28,54 @@ gulp.task('lint-all', closureLint); gulp.task('fix-all', closureFix); gulp.task('browserify', browserifyBundle); gulp.task('compile-sass', compileSASS); +gulp.task('clean', clean); // Web specific gulp.task('build-web', ['browserify', 'compile-sass'], buildWeb); // GAS specific -gulp.task('deploy-gas', ['swap-tags'], deployGAS); -gulp.task('swap-tags', ['build-gas'], replaceTags); +gulp.task('deploy-gas', ['build-gas'], deployGAS); gulp.task('build-gas', ['browserify', 'compile-sass'], buildGAS); - /** * Bundles up client.js (and all required functionality) and places it in a build directory. + * We apply a stringify transform. This package finds requires that require .html files. It swaps them out for the actual text. + * This is great for breaking up html and can be used as a templating tool. * * @return {stream} the stream as the completion hint to the gulp engine */ function browserifyBundle() { - return browserify('./src/client/js/client.js') - .bundle() - .on('error', function(e) { - gutil.log(e); - }) - .pipe(source('bundle.js')) - .pipe(gulp.dest('./build/common')); + // we define our input files, which we want to have bundled: + var files = [ + './src/client/js/client.js', + './src/client/js/rich-text-editor.js' + ]; + + // map them to our stream function + var tasks = files.map(function(entry) { + var path = entry.split('/'); + + return browserify(entry) + .transform(stringify, { + appliesTo: { + includeExtensions: ['.html'] + }, + minify: false + }) + .bundle() + .on('error', function(e) { + gutil.log(e); + }) + .pipe(source(path[path.length - 1])) + // rename them to have "bundle as postfix" + .pipe(rename({ + extname: '.bundle.js' + })) + .pipe(gulp.dest('./build/common')); + }); + + // create a merged stream + return es.merge.apply(null, tasks); } @@ -54,30 +87,17 @@ function browserifyBundle() { */ function buildGAS() { - gulp.src('./src/client/html/**', { - base: './src/client' - }) - .pipe(gulp.dest('./build/gas')); - - // GAS - return gulp.src('./src/GAS/*') - .pipe(gulp.dest('./build/gas/GAS')); -} - - -/** - * Replaces all script tags and css links. - * Note: This is done relative to this gulpfile. - * All swap tags are relative to this gulpfile. - * - * @return {stream} the stream as the completion hint to the gulp engine - */ -function replaceTags() { - return gulp.src('./build/gas/html/*.html') - .pipe(htmlProcessor({ - includeBase: './' - })) - .pipe(gulp.dest('./build/gas/html/')); + gulp.src('./src/client/html/**', { + base: './src/client' + }) + .pipe(htmlProcessor({ + includeBase: './' + })) + .pipe(gulp.dest('./build/gas')); + + // GAS + return gulp.src('./src/gas/*') + .pipe(gulp.dest('./build/gas/gas')); } @@ -87,23 +107,23 @@ function replaceTags() { * @return {stream} the stream as the completion hint to the gulp engine */ function buildWeb() { - gulp.src('./build/common/bundle.js') - .pipe(gulp.dest('./build/web/client/js')); + gulp.src('./build/common/*.bundle.js') + .pipe(gulp.dest('./build/web/client/js')); - gulp.src('./src/client/css/*') - .pipe(gulp.dest('./build/web/client/css')); + gulp.src('./src/client/css/*') + .pipe(gulp.dest('./build/web/client/css')); - gulp.src('./src/client/html/*') - .pipe(gulp.dest('./build/web/client/html')); + gulp.src('./src/client/html/*') + .pipe(gulp.dest('./build/web/client/html')); - gulp.src('./src/client/images/*') - .pipe(gulp.dest('./build/web/client/images')); + gulp.src('./src/client/images/*') + .pipe(gulp.dest('./build/web/client/images')); - gulp.src('./build/common/css/*') - .pipe(gulp.dest('./build/web/client/css')); + gulp.src('./build/common/css/*') + .pipe(gulp.dest('./build/web/client/css')); - return gulp.src('./src/GAS/*') - .pipe(gulp.dest('./build/web/GAS')); + return gulp.src('./src/gas/*') + .pipe(gulp.dest('./build/web/gas')); } @@ -115,48 +135,53 @@ function buildWeb() { * @return {stream} the stream as the completion hint to the gulp engine */ function deployGAS(cb) { - return exec('gapps push', function(err, stdout, stderr) { - console.log(stdout); - console.log(stderr); - cb(err); - }); + return exec('gapps push', function(err, stdout, stderr) { + console.log(stdout); + console.log(stderr); + cb(err); + }); } /** - * Opens up the project in GAS in chrome. + * Opens up the project in GAS in Google Chrome. * Calls browserifyBundle, then buildGAS, then deployGAS. * - * @param {callback} cb - a callback so the engine knows when it'll be done * @return {stream} the stream as the completion hint to the gulp engine */ -function openGAS(cb) { - // Open the project in chrome - var key = JSON.parse(fs.readFileSync('gapps.config.json', 'utf8')).fileId; - - var chrome = 'start chrome https://script.google.com/a/edmonton.ca/d/' + key + '/edit'; - return exec(chrome, function(err, stdout, stderr) { - console.log(stdout); - console.log(stderr); - cb(err); - }); +function openGAS() { + var key = JSON.parse(fs.readFileSync('gapps.config.json', 'utf8')).fileId; + var url = 'https://script.google.com/a/edmonton.ca/d/' + key + '/edit'; + + var browser = os.platform() === 'win32' ? 'chrome' : ( + os.platform() === 'linux' ? 'google-chrome' : ( + os.platform() === 'darwin' ? 'google chrome' : 'firefox')); + + var options = { + uri: url, + app: browser + }; + + return gulp.src(__filename) + .pipe(open(options)); } /** - * Opens up the project in chrome. + * Opens up the project in Google Chrome. * Calls browserifyBundle, then buildWeb. * - * @param {callback} cb - a callback so the engine knows when it'll be done * @return {stream} the stream as the completion hint to the gulp engine */ -function openWeb(cb) { - var chrome = 'start chrome ./build/web/client/html/ListSetupSidebar.html'; - return exec(chrome, function(err, stdout, stderr) { - console.log(stdout); - console.log(stderr); - cb(err); - }); +function openWeb() { + var browser = os.platform() === 'win32' ? 'chrome' : ( + os.platform() === 'linux' ? 'google-chrome' : ( + os.platform() === 'darwin' ? 'google chrome' : 'firefox')); + + return gulp.src('./build/web/client/html/mailman.html') + .pipe(open({ + app: browser + })); } @@ -166,15 +191,15 @@ function openWeb(cb) { * @return {stream} the stream as the completion hint to the gulp engine */ function closureLint() { - // flags: https://github.com/jmendiara/node-closure-linter-wrapper#flags - var lintOptions = { - flags: ['--max_line_length 120', '--strict'] - }; - - // Output all failures to the console, and \then fail. - return gulp.src(['./src/**/*.js']) - .pipe(gjslint(lintOptions)) - .pipe(gjslint.reporter('console')); + // flags: https://github.com/jmendiara/node-closure-linter-wrapper#flags + var lintOptions = { + flags: ['--max_line_length 120', '--strict'] + }; + + // Output all failures to the console, and then fail. + return gulp.src(['./src/**/*.js']) + .pipe(gjslint(lintOptions)) + .pipe(gjslint.reporter('console')); } @@ -187,27 +212,38 @@ function closureLint() { */ function closureFix(cb) { - var fixJS = 'fixjsstyle --strict --max_line_length 120 -r ./src'; + var fixJS = 'fixjsstyle --strict --max_line_length 120 -r ./src'; - return exec(fixJS, function(err, stdout, stderr) { - console.log(stdout); - console.log(stderr); - cb(err); - }); + return exec(fixJS, function(err, stdout, stderr) { + console.log(stdout); + console.log(stderr); + cb(err); + }); } /** - * Compiles SASS (?) ¯\_(ツ)_/¯ + * Compiles SASS into CSS. * * @return {stream} the stream as the completion hint to the gulp engine */ function compileSASS() { - return gulp.src('./src/client/sass/*.scss') - .pipe(sass().on('error', function(error) { - var message = new gutil.PluginError('sass', error.messageFormatted).toString(); - process.stderr.write(message + '\n'); - process.exit(1); - })) - .pipe(gulp.dest('./build/common/css')); + return gulp.src('./src/client/sass/*.scss') + .pipe(sass().on('error', function(error) { + var message = new gutil.PluginError('sass', error.messageFormatted).toString(); + process.stderr.write(message + '\n'); + process.exit(1); + })) + .pipe(gulp.dest('./build/common/css')); +} + +/** + * Removes all builds. + * + * @return {stream} the stream as the completion hint to the gulp engine + */ +function clean() { + return del([ + 'build/**/*' + ]); } diff --git a/package.json b/package.json index 2d191bc..98afe48 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,21 @@ }, "devDependencies": { "browserify": "^13.1.0", + "del": "^2.2.2", + "event-stream": "^3.3.4", + "gulp": "^3.9.1", "gulp-gjslint": "^0.1.5", "gulp-htmlprocessor": "^0.1.1", + "gulp-open": "^2.0.0", + "gulp-rename": "^1.2.2", "gulp-replace": "^0.5.4", "gulp-sass": "^2.3.2", "gulp-uglify": "^2.0.0", "gulp-util": "^3.0.7", - "gulp": "^3.9.1", "jquery": "^3.1.0", + "jquery.hotkeys": "^0.1.0", + "pubsub-js": "^1.5.4", + "stringify": "^5.1.0", "uglify-js": "^1.3.5", "vinyl-source-stream": "^1.1.0" }, diff --git a/src/GAS/Comparison.js b/src/GAS/Comparison.js deleted file mode 100644 index 1e9419e..0000000 --- a/src/GAS/Comparison.js +++ /dev/null @@ -1,12 +0,0 @@ -/*** - * I'm not too sure if I'll end up using this file much. It may be that I only need the 'Text is exactly' functionality. - * - */ - -Comparison = { - 'Text is exactly': textIsExactly -}; - -function textIsExactly(text, mustBe) { - return text === mustBe; -} diff --git a/src/GAS/Events.js b/src/GAS/Events.js deleted file mode 100644 index 91b6610..0000000 --- a/src/GAS/Events.js +++ /dev/null @@ -1,92 +0,0 @@ -function onInstall(e) { - //Install triggers - PropertiesService.getDocumentProperties().setProperty(PROPERTY_SS_ID, SpreadsheetApp.getActiveSpreadsheet().getId()); - - onOpen(e); -} - -function onOpen(e) { - SpreadsheetApp.getUi() - .createAddonMenu() //'Defect Tracker' - .addItem('Set Up Email List', 'openListSetUpSidebar') - .addToUi(); - - PropertiesService.getDocumentProperties().setProperty(PROPERTY_SS_ID, SpreadsheetApp.getActiveSpreadsheet().getId()); -} - - -/** - * Creates an HTML sidebar for creating/viewing mailman rules. - * - */ -function openListSetUpSidebar() { - var ui = HtmlService.createHtmlOutputFromFile('ListSetupSidebar') - .setTitle('Set Up Email List') - .setSandboxMode(HtmlService.SandboxMode.IFRAME); - - SpreadsheetApp.getUi().showSidebar(ui); -} - -function onTrigger() { - Logger.log('Running trigger function...'); - - var headers = 1; - - // Get all rules (TODO Multiple rules) - var rules = getRule(); - SPREADSHEET_ID = PropertiesService.getDocumentProperties().getProperty(PROPERTY_SS_ID); - - // Validate each rule for each row - var ss = SpreadsheetApp.openById(SPREADSHEET_ID); - var sheets = ss.getSheets(); - - var to = rules.to.split('!'); - var cc = rules.cc === null ? null : rules.cc.split('!'); - var bcc = rules.bcc === null ? null : rules.bcc.split('!'); - var subject = rules.subject.split('!'); - var body = rules.body.split('!'); - var comparison = Comparison[rules.comparison]; - var range = rules.range.split('!'); - var previous = rules.previous.split('!'); - - var dataVisible = true; - var rowsChecked = 0; - while (dataVisible) { - - var rangeCell = ss.getSheetByName(range[0]).getRange(range[1]).getCell(rowsChecked + headers + 1, 1); - - if (rangeCell.getDisplayValue() === rules.value) { - // Send emails - - var toCell = ss.getSheetByName(to[0]).getRange(to[1]).getCell(rowsChecked + headers + 1, 1); - var ccCell = cc === null ? null : ss.getSheetByName(cc[0]).getRange(cc[1]).getCell(rowsChecked + headers + 1, 1); - var bccCell = bcc === null ? null : ss.getSheetByName(bcc[0]).getRange(bcc[1]).getCell(rowsChecked + headers + 1, 1); - var subjectCell = ss.getSheetByName(subject[0]).getRange(subject[1]).getCell(rowsChecked + headers + 1, 1); - var bodyCell = ss.getSheetByName(body[0]).getRange(body[1]).getCell(rowsChecked + headers + 1, 1); - - //GMAILAPP - GmailApp.sendEmail(toCell.getDisplayValue(), subjectCell.getDisplayValue(), bodyCell.getDisplayValue(), { - bcc: (bccCell === null ? null : bccCell.getDisplayValue()), - cc: (ccCell === null ? null : ccCell.getDisplayValue()) - }); - Logger.log('Send to: ' + toCell.getDisplayValue()); - Logger.log('Subject: ' + subjectCell.getDisplayValue()); - Logger.log('Body: ' + bodyCell.getDisplayValue()); - - // Update Last sent: - var previousCell = ss.getSheetByName(previous[0]).getRange(previous[1]).getCell(rowsChecked + headers + 1, 1); - Logger.log('Previous Email: ' + previousCell.getDisplayValue()); - - var now = new Date(); - - // Date.getMonth is 0 indexed. January is 0. - previousCell.setValue(now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate()); - } - - rowsChecked++; - if (rowsChecked >= 5) { - dataVisible = false; - } - } - -} diff --git a/src/GAS/Global Variables.js b/src/GAS/Global Variables.js deleted file mode 100644 index 3b638b2..0000000 --- a/src/GAS/Global Variables.js +++ /dev/null @@ -1,4 +0,0 @@ -var PROPERTY_RULE = 'RULE'; -var PROPERTY_SS_ID = 'SPREADSHEET_ID'; - -var SPREADSHEET_ID = null; diff --git a/src/GAS/Server Callbacks.js b/src/GAS/Server Callbacks.js deleted file mode 100644 index ca63f10..0000000 --- a/src/GAS/Server Callbacks.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Called from the client-side. Returns the current selection in A1 notation. - * @return {string} The sheets selection in A1 notation. - */ -function getSheetSelection() { - var column = columnToLetter(SpreadsheetApp.getActiveRange().getColumn()); - var sheet = SpreadsheetApp.getActiveRange().getSheet().getName(); - - return sheet + '!' + column + ':' + column; -} - - -/** - * The ultimate output of the client-side html form. - * Used to create a new rule to check. - * - * @param {string} to The A1 notation for the column containing the primary recipients - * @param {string} cc The A1 notation for the column containing the cc recipients - * @param {string} bcc The A1 notation for the column containing the bcc recipients - * @param {string} subject The A1 notation for the column containing the subject - * @param {string} body The A1 notation for the body column - * @param {string} range The A1 notation for the range to be searched/emailed - * @param {string} comparison The type of comparison (TODO only supports Text is exactly...) - * @param {string} value The value corresponding to the comparison - * @param {string} previous The last time this row sent an email - * @return {string} Success value that informs users of issues/success - */ -function createRule(to, cc, bcc, subject, body, range, comparison, value, previous) { - // Test all the values - - var rule = { - 'to': to, - 'cc': cc, - 'bcc': bcc, - 'subject': subject, - 'body': body, - 'range': range, - 'comparison': comparison, - 'value': value, - 'previous': previous - }; - - PropertiesService.getDocumentProperties().setProperty(PROPERTY_RULE, JSON.stringify(rule)); - - Logger.log(rule); - - // TEMP - onTrigger(); -} diff --git a/src/GAS/Utility.js b/src/GAS/Utility.js deleted file mode 100644 index 7d55d00..0000000 --- a/src/GAS/Utility.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Given a range in A1 notation, this function extracts the sheet name. - * Example: TestSheet1!A4:D10 returns TestSheet1. - * - * @param {string} a1Notation A range in A1 notation. - * @return {string} The sheet name. - */ -function extractSheetName(a1Notation) { - return a1Notation.substr(0, a1Notation.search('!')); -} - - -/** - * Given a range in A1 notation, this function extracts the range. - * Example: TestSheet1!A4:D10 returns A4:D10. - * Note: This does not validate the range. It could be just gibberish. - * All this does is return some text after a !. - * - * @param {string} a1Notation A range in A1 notation. - * @return {string} The range in A1 notation (no sheet name). - */ -function extractRange(a1Notation) { - return a1Notation.substr(a1Notation.search('!') + 1, a1Notation.length); -} - - -/** - * Ensures a given rule makes sense. It logs issues with the rule and also returns the issues in a string array. - * - * @param {Object} rule An object that contains to, cc, bcc, subject, body, range, comparison, value. - * @return {Array.} A list of issues with the rules. If this is empty, the rule is good! - */ -function validateRule(rule) { - -} - - -/** - * Get the rule for this document. - * - * @return {object} The rule in object form. - */ -function getRule() { - return JSON.parse(PropertiesService.getDocumentProperties().getProperty(PROPERTY_RULE)); -} - - -/** - * Source: http://stackoverflow.com/questions/21229180/convert-column-index-into-corresponding-column-letter - * Converts a column index into the column letter. - * - * @param {number} column The column index - * @return {string} The column letters - */ -function columnToLetter(column) { - var temp, letter = ''; - while (column > 0) { - temp = (column - 1) % 26; - letter = String.fromCharCode(temp + 65) + letter; - column = (column - temp - 1) / 26; - } - return letter; -} - - -/** - * Creates a rule with default test information. Used for testing purposes. - * - */ -function fillTestInfo() { - createRule('Defects!I:I', null, null, 'Defects Email!A:A', 'Defects Email!B:B', 'Defects Email!C:C', 'Text is exactly', 'TRUE', 'Defects Email!D:D'); -} - - -/** - * Logs the Documents properties. Used for testing purposes. - * - */ -function checkDocumentProperties() { - Logger.log(PropertiesService.getDocumentProperties().getProperties()); -} - - -/** - * Cleans all properties. We need the sheet id stored as a property. Remember to get this id again. - * - */ -function removeProperties() { - PropertiesService.getDocumentProperties().deleteAllProperties(); -} diff --git a/src/GAS/events.js b/src/GAS/events.js new file mode 100644 index 0000000..5da9f62 --- /dev/null +++ b/src/GAS/events.js @@ -0,0 +1,72 @@ +/** + * Prepares the add on after a user has opted to install it. + * TODO Test this + * + * @param {object} e The event object https://developers.google.com/apps-script/guides/triggers/events + */ +function onInstall(e) { + onOpen(e); +} + + +/** + * Called when the Spreadsheet is opened. + * + * @param {object} e The event object https://developers.google.com/apps-script/guides/triggers/events + */ +function onOpen(e) { + var menu = SpreadsheetApp.getUi().createAddonMenu(); + + menu.addItem('Setup', 'openSidebar') + .addItem('Feedback', 'openFeedbackDialog') + .addToUi(); +} + + +/** + * Creates an HTML sidebar for creating/viewing mailman rules. + * + */ +function openSidebar() { + PropertiesService.getDocumentProperties().setProperty(PROPERTY_SS_ID, SpreadsheetApp.getActiveSpreadsheet().getId()); + + var ui = HtmlService.createHtmlOutputFromFile('mailman') + .setTitle('Mailman') + .setSandboxMode(HtmlService.SandboxMode.IFRAME); + + if (!validateTriggers()) { + deleteForThisSheet(); + log('Triggers should be rebuilt.'); + createTriggerBasedEmail(); // IMPORTANT + } + + SpreadsheetApp.getUi().showSidebar(ui); +} + + +/** + * Opens the feedback to dialog which directs users to the form. + * + */ +function openFeedbackDialog() { + var ui = HtmlService.createHtmlOutputFromFile('feedback-dialog') + .setTitle('Feedback') + .setSandboxMode(HtmlService.SandboxMode.IFRAME); + + SpreadsheetApp.getUi().showModalDialog(ui, 'Feedback'); +} + + +/** + * Creates an HTML modal for creating/viewing Mailman email templates. + * + */ +function openModalDialog() { + var ui = HtmlService.createHtmlOutputFromFile('rich-text-editor') + .setTitle('Mailman') + .setSandboxMode(HtmlService.SandboxMode.IFRAME) + .setHeight(600) + .setWidth(750); + + SpreadsheetApp.getUi().showModalDialog(ui, ' '); +} diff --git a/src/GAS/global-variables.js b/src/GAS/global-variables.js new file mode 100644 index 0000000..97d078d --- /dev/null +++ b/src/GAS/global-variables.js @@ -0,0 +1,5 @@ +var PROPERTY_SS_ID = 'SPREADSHEET_ID'; +var RULE_KEY = 'MAILMAN_PROP_RULES'; +var MAILMAN_VERSION = 'pre0.4'; + +var SPREADSHEET_ID = null; diff --git a/src/GAS/server-callbacks.js b/src/GAS/server-callbacks.js new file mode 100644 index 0000000..aeff300 --- /dev/null +++ b/src/GAS/server-callbacks.js @@ -0,0 +1,64 @@ + + +/** + * Creates a trigger for sending emails. + * + */ +function createTriggerBasedEmail() { + try { + SPREADSHEET_ID = PropertiesService.getDocumentProperties().getProperty(PROPERTY_SS_ID); + var ss = SpreadsheetApp.openById(SPREADSHEET_ID); + + log('Creating trigger.'); + ScriptApp.newTrigger('sendManyEmails') + .timeBased() + .everyHours(1) + .create(); + } + catch (e) { + log('Error: ' + e); + throw e; + } +} + + +/** + * Launches the Rich Text Editor. + * + * @return {string} The id of the newly created dialog. + */ +function launchRTE() { + var dialogId = Utilities.base64Encode(Math.random()); + + var template = HtmlService.createTemplateFromFile('rich-text-editor'); + template.dialogId = dialogId; + + var ui = template.evaluate() + .setTitle('Mailman') + .setSandboxMode(HtmlService.SandboxMode.IFRAME) + .setHeight(600) + .setWidth(750); + + SpreadsheetApp.getUi().showModalDialog(ui, ' '); + + return 'dialog'; +} + + +/** + * Gets the names of all the available sheets. + * + * @return {Array} The names of all the sheets. + */ +function getSheets() { + SPREADSHEET_ID = PropertiesService.getDocumentProperties().getProperty(PROPERTY_SS_ID); + var ss = SpreadsheetApp.openById(SPREADSHEET_ID); + var sheets = ss.getSheets(); + + var names = []; + for (var i = 0; i < sheets.length; i++) { + names.push(sheets[i].getName()); + } + + return names; +} diff --git a/src/GAS/utility.js b/src/GAS/utility.js new file mode 100644 index 0000000..f06c3f5 --- /dev/null +++ b/src/GAS/utility.js @@ -0,0 +1,172 @@ + + +/** + * Ensures the assigned trigger is valid. + * + * @param {Trigger} trigger The Trigger to test. + * @return {boolean} True if the Trigger is valid, false otherwise. + */ +function validateTrigger(trigger) { + if (trigger.getEventType() !== ScriptApp.EventType.CLOCK) { + log('Invalid trigger event type'); + return false; + } + if (trigger.getHandlerFunction() !== 'sendManyEmails') { + log('Invalid trigger function'); + return false; + } + + return true; +} + + +/** + * Validates all of Mailman's triggers. Logs any issues for debug purposes. + * + * @return {boolean} True if the triggers are set up properly, false if they are set up incorrectly. + */ +function validateTriggers() { + SPREADSHEET_ID = PropertiesService.getDocumentProperties().getProperty(PROPERTY_SS_ID); + var ss = SpreadsheetApp.openById(SPREADSHEET_ID); + + var triggers = ScriptApp.getUserTriggers(ss); + log('Triggers:' + triggers.length); + if (triggers.length !== 1) { + log('Incorrect number of triggers: ' + triggers.length); + return false; + } + + return validateTrigger(triggers[0]); +} + + +/** + * This gets the values of the header row for the given EmailRule. It's worth noting that + * the parameter object isn't actually an EmailRule, it just needs to have the sheet and headerRow + * properties. + * + * @param {Object} rule The EmailRule to find header values for. + * @param {string} rule.sheet The sheet name. + * @param {string} rule.headerRow The 1-indexed row of the header. + * @return {Array} The array of values. + */ +function getHeaderStrings(rule) { + SPREADSHEET_ID = PropertiesService.getDocumentProperties().getProperty(PROPERTY_SS_ID); + var ss = SpreadsheetApp.openById(SPREADSHEET_ID); + var sheet = ss.getSheetByName(rule.sheet); + + var value = getValues(sheet, parseInt(rule.headerRow) - 1); + + return value; +} + + +/** + * Gets an array of the values in a specific sheets row. + * + * @param {Sheet} sheet The sheet to get the values from. + * @param {number} rowIndex The zero-based index of the row to retrieve values for. + * @return {Array} The array of values. + */ +function getValues(sheet, rowIndex) { + var range = sheet.getDataRange(); + + var row = range.offset(rowIndex, 0, 1, range.getNumColumns()); + + var values = []; + for (var i = 1; i <= row.getNumColumns(); i++) { + values.push(row.getCell(1, i).getDisplayValue()); + } + + return values; +} + + +/** + * This function replaces all instances of <> with the data in headerToData. + * + * @param {string} text The string that contains the tags. + * @param {Object} headerToData A key-value pair where the key is a column name + * and the value is the data in the column. + * @return {string} The text with all tags replaced with data. + */ +function replaceTags(text, headerToData) { + var dataText = text.replace(/<<.*?>>/g, function(match, offset, string) { + var columnName = match.slice(2, match.length - 2); + return headerToData[columnName]; + }); + + return dataText; +} + + +/** + * Source: http://stackoverflow.com/questions/21229180/convert-column-index-into-corresponding-column-letter + * Converts a column index into the column letter. + * + * @param {number} column The column index + * @return {string} The column letters + */ +function columnToLetter(column) { + var temp, letter = ''; + while (column > 0) { + temp = (column - 1) % 26; + letter = String.fromCharCode(temp + 65) + letter; + column = (column - temp - 1) / 26; + } + return letter; +} + + + /** + * Get the rule for this document. + * + * @return {object} The rule in object form. + */ + function getRules() { + return JSON.parse(load(RULE_KEY)); + } + + +/** + * Deletes all triggers associated with the given Sheet. + * + * @param {Sheet} sheet The Sheet to remove triggers from. + */ +function deleteAllTriggers(sheet) { + var triggers = ScriptApp.getUserTriggers(sheet); + + for (var i = 0; i < triggers.length; i++) { + ScriptApp.deleteTrigger(triggers[i]); + } +} + + +/** + * Removes the triggers for the Sheet the add on is installed in. + * + */ +function deleteForThisSheet() { + SPREADSHEET_ID = PropertiesService.getDocumentProperties().getProperty(PROPERTY_SS_ID); + var ss = SpreadsheetApp.openById(SPREADSHEET_ID); + + deleteAllTriggers(ss); +} + + +/** + * Logs the Documents properties. Used for testing purposes. + * + */ +function checkDocumentProperties() { + Logger.log(PropertiesService.getDocumentProperties().getProperties()); +} + + +/** + * Cleans all properties. We need the sheet id stored as a property. Remember to get this id again. + * + */ +function removeProperties() { + PropertiesService.getDocumentProperties().deleteAllProperties(); +} diff --git a/src/client/css/styles.css b/src/client/css/styles.css deleted file mode 100644 index a5686a2..0000000 --- a/src/client/css/styles.css +++ /dev/null @@ -1,539 +0,0 @@ -.hover-text:hover { - text-decoration: underline; -} - -textarea:focus { - outline: none !important; - border: none; - box-shadow: none; -} - -.branding-below { - bottom: 56px; - top: 0; -} - -.branding-text { - left: 7px; - position: relative; - top: 3px; -} - -.border-bottom { - border-bottom: 1px solid #cfcfcf; - margin: 0px 0px 0px 0px -} - -.input-full { - box-sizing: border-box; - box-shadow: none; - border: transparent; - background-color: white; - font: 13px Arial; - margin: 4px 0px 4px 0px; - outline: none; - padding: 4px 1px 4px 2px; - resize: none; - -webkit-box-sizing: border-box; - width: 60%; -} - -.rest-right { - float: right; -} - -.show-text { - color: #777; - font-size: 13px; - padding: 0 8px 0 0; -} - -.select-text { - cursor: pointer; - -webkit-user-select: none; -} - -.blank-textarea { - resize: none; - border: hidden; - width: 100%; - height: 100%; - font: 13px Arial; -} - -.fixed-button { - height: 30px; - padding: 10px 0px 10px 0px; -} - - -/**** Taken from Google's Addons Stylesheet: ****/ - - -/* */ - -body { - color: #222; - font: 13px/18px arial, sans-serif; - margin: 0; -} - - -/** Google's Material Icons */ - - -/* Rules for sizing the icon. */ - -.material-icons.md-11 { - font-size: 11px; -} - -.material-icons.md-13 { - font-size: 13px; -} - -.material-icons.md-18 { - font-size: 18px; -} - -.material-icons.md-24 { - font-size: 24px; -} - -.material-icons.md-36 { - font-size: 36px; -} - -.material-icons.md-48 { - font-size: 48px; -} - - -/* Rules for using icons as black on a light background. */ - -.material-icons.md-dark { - color: rgba(0, 0, 0, 0.54); -} - -.material-icons.md-dark.md-inactive { - color: rgba(0, 0, 0, 0.26); -} - - -/* Rules for using icons as white on a dark background. */ - -.material-icons.md-light { - color: rgba(255, 255, 255, 1); -} - -.material-icons.md-light.md-inactive { - color: rgba(255, 255, 255, 0.3); -} - - -/** End Material Icons */ - -.sidebar { - -moz-box-sizing: border-box; - box-sizing: border-box; - overflow-y: auto; - padding: 12px; - position: absolute; - width: 100%; -} - -.bottom, .bottom-left, .button-bar { - bottom: 0; - position: absolute; -} - -.gray, .grey { - color: #777; -} - -.button.disabled, button:disabled, input[type="button"]:disabled, input[type="image"]:disabled, input[type="reset"]:disabled, input[type="submit"]:disabled { - background: #fff; - border: 1px solid #dcdcdc; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; - color: #333; - opacity: .5; -} - -.button, button, input[type="button"], input[type="image"], input[type="reset"], input[type="submit"] { - background: -moz-linear-gradient(top, #f5f5f5, #f1f1f1); - background: -ms-linear-gradient(top, #f5f5f5, #f1f1f1); - background: -o-linear-gradient(top, #f5f5f5, #f1f1f1); - background: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1); - background: linear-gradient(top, #f5f5f5, #f1f1f1); - border: 1px solid #dcdcdc; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - border-radius: 2px; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; - color: #333; - cursor: pointer; - font-family: arial, sans-serif; - font-size: 11px; - font-weight: bold; - height: 29px; - line-height: 27px; - margin: 0; - min-width: 72px; - outline: 0; - padding: 0 8px; - text-align: center; - white-space: nowrap; -} - -.button.action, .button.blue, button.action, button.blue, input[type="button"].action, input[type="button"].blue, input[type="submit"].action, input[type="submit"].blue { - background: -moz-linear-gradient(top, #4d90fe, #4787ed); - background: -ms-linear-gradient(top, #4d90fe, #4787ed); - background: -o-linear-gradient(top, #4d90fe, #4787ed); - background: -webkit-linear-gradient(top, #4d90fe, #4787ed); - background: linear-gradient(top, #4d90fe, #4787ed); - border: 1px solid #3079ed; - color: #fff; -} - -.button+.button, button+button, input+input { - margin-left: 12px; -} - - -/**** Google Icons Styles ****/ - -.docs-icon-grid { - left: 0; - top: -168px; -} - - -/* Second */ - -.docs-icon { - direction: ltr; - text-align: left; - height: 21px; - overflow: hidden; - vertical-align: middle; - width: 21px; -} - -.goog-inline-block { - position: relative; - display: -moz-inline-box; - display: inline-block; -} - - -/* Bottom-most */ - -.jfk-button { - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - cursor: default; - font-size: 11px; - font-weight: bold; - text-align: center; - white-space: nowrap; - margin-right: 16px; - height: 27px; - line-height: 27px; - min-width: 54px; - outline: 0px; - padding: 0 8px; -} - -.jfk-button-standard { - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; - background-color: #f5f5f5; - background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: -moz-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: -ms-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: -o-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: linear-gradient(top, #f5f5f5, #f1f1f1); - color: #333; - border: 1px solid #dcdcdc; - border: 1px solid rgba(0, 0, 0, 0.1); -} - -.jfk-button-narrow { - min-width: 34px; - padding: 0; -} - -.waffle-range-selection-button { - background: transparent!important; - border: transparent!important; - cursor: pointer; - margin: 0 0 0 -13px; - opacity: .70; - white-space: nowrap; -} - - -/**** Google Sheets Conditional Format CSS ****/ - - -/* Apply to range */ - -.waffle-conditionalformat-range-picker { - padding: 0 18px 18px 18px; -} - -.waffle-conditionalformat-edit-pill-section-label { - color: #646464; - font-family: Arial; - font-size: 12px; - line-height: 14px; - margin-bottom: 6px; - margin-top: 18px; -} - -.waffle-conditionalformat-range-wrapper { - color: #444; - font-family: Arial; - font-size: 12px; -} - -.waffle-range-selection-container { - background: #fff; - border: 1px solid #d9d9d9; - border-top: 1px solid #c0c0c0; - min-width: 20px; - width: 100%; - padding: 0px; -} - -.waffle-range-selection-container-focus { - border: 1px solid #4d90fe; - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); - -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); - outline: none -} - -.waffle-range-selection-input, .waffle-range-selection-input:focus { - background: transparent!important; - border: none!important; - box-sizing: border-box; - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; - height: 25px; - margin: 0; - outline: none!important; - padding: 1px 8px!important; - width: 100%; -} - -.waffle-range-selection-button-container { - overflow: hidden; - padding: 0 0 0 8px; - text-align: right; - width: 21px; -} - - -/* Override the default addons css */ - -td.waffle-row { - padding: 0px; -} - - -/* Format cells if... */ - -.waffle-conditionalformat-boolean-condition-picker, .waffle-conditionalformat-gradient-format-picker { - padding: 0px 18px 18px 18px; -} - -.goog-flat-menu-button { - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - background-color: #f5f5f5; - background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: -moz-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: -ms-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: -o-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: linear-gradient(top, #f5f5f5, #f1f1f1); - border: 1px solid #dcdcdc; - color: #333; - cursor: default; - font-size: 11px; - font-weight: bold; - line-height: 27px; - list-style: none; - margin: 0 2px; - min-width: 46px; - outline: none; - padding: 0 18px 0 6px; - text-align: center; - text-decoration: none; -} - -.goog-flat-menu-button:hover { - background-color: #f8f8f8; - background-image: -webkit-linear-gradient(top, #f8f8f8, #f1f1f1); - background-image: -moz-linear-gradient(top, #f8f8f8, #f1f1f1); - background-image: -ms-linear-gradient(top, #f8f8f8, #f1f1f1); - background-image: -o-linear-gradient(top, #f8f8f8, #f1f1f1); - background-image: linear-gradient(top, #f8f8f8, #f1f1f1); - -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .1); - -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, .1); - box-shadow: 0 1px 1px rgba(0, 0, 0, .1); - border-color: #c6c6c6; - color: #111 -} - -.waffle-conditionalformat-condition-type-select .jfk-select, .waffle-conditionalformat-condition-date-select .jfk-select { - margin: 0; - width: 238px; -} - -.waffle-conditionalformat-condition-type-select .goog-flat-menu-button, .waffle-conditionalformat-condition-date-select .goog-flat-menu-button { - text-align: left; -} - -.jfk-select .goog-flat-menu-button-caption { - overflow: hidden; - width: 100%; -} - -.goog-flat-menu-button-caption { - vertical-align: top; - white-space: nowrap; -} - -.jfk-select .goog-flat-menu-button-dropdown { - background: url(//ssl.gstatic.com/ui/v1/disclosure/grey-disclosure-arrow-up-down.png) center no-repeat; - border: none; - height: 11px; - margin-top: -4px; - width: 7px; -} - -.goog-flat-menu-button-dropdown { - border-color: #777 transparent; - border-style: solid; - border-width: 4px 4px 0 4px; - height: 0; - width: 0; - position: absolute; - right: 5px; - top: 12px; -} - -.waffle-conditionalformat-arg1 { - margin: 6px 6px 0 0; - width: 246px; -} - -.jfk-textinput { - -webkit-border-radius: 1px; - -moz-border-radius: 1px; - border-radius: 1px; - border: 1px solid #d9d9d9; - border-top: 1px solid #c0c0c0; - font-size: 13px; - height: 25px; - padding: 1px 8px; -} - - -/* Google DDLs */ - -.goog-menu { - z-index: 1003; -} - -.goog-menu { - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; - -webkit-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - -moz-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - -webkit-transition: opacity 0.218s; - -moz-transition: opacity 0.218s; - -o-transition: opacity 0.218s; - transition: opacity 0.218s; - background: #fff; - border: 1px solid #ccc; - border: 1px solid rgba(0, 0, 0, .2); - cursor: default; - font-size: 13px; - margin: 0; - outline: none; - padding: 6px 0; - position: absolute; -} - -.goog-menuitem, .goog-tristatemenuitem, .goog-filterobsmenuitem { - position: relative; - color: #333; - cursor: pointer; - list-style: none; - margin: 0; - padding: 6px 8em 6px 30px; - white-space: nowrap; -} - -.goog-menuseparator { - border-top: 1px solid #ebebeb; - margin-top: 6px; - margin-bottom: 6px; -} - -.goog-menuitem, .goog-tristatemenuitem, .goog-filterobsmenuitem { - position: relative; - color: #333; - cursor: pointer; - list-style: none; - margin: 0; - padding: 6px 8em 6px 30px; - white-space: nowrap; -} - - -/* Used for the slide between divs */ - -.slider { - width: 300px; - height: 100%; - overflow: hidden; - position: relative; -} - -.slide { - float: left; - width: 300px; -} - -.holder { - width: 300%; - padding: 8px 0px 0px 0px; -} - - -/* Used for the Last Sent Column */ - - -/*Our custom CSS begins below*/ - -.icon-right { - border: none; - height: 11px; - width: 7px; - position: absolute; - right: 21px; - top: 6px; -} diff --git a/src/client/html/ListSetupSidebar.html b/src/client/html/ListSetupSidebar.html deleted file mode 100644 index 94664fe..0000000 --- a/src/client/html/ListSetupSidebar.html +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - - - - - diff --git a/src/client/html/feedback-dialog.html b/src/client/html/feedback-dialog.html new file mode 100644 index 0000000..558004d --- /dev/null +++ b/src/client/html/feedback-dialog.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/src/client/html/mailman.html b/src/client/html/mailman.html new file mode 100644 index 0000000..08481aa --- /dev/null +++ b/src/client/html/mailman.html @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ +
+ +
+ +
+ + + diff --git a/src/client/html/rich-text-editor.html b/src/client/html/rich-text-editor.html new file mode 100644 index 0000000..aeb9f8a --- /dev/null +++ b/src/client/html/rich-text-editor.html @@ -0,0 +1,182 @@ + + + + + + + + Mailman + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/src/client/js/card/card-base.html b/src/client/js/card/card-base.html new file mode 100644 index 0000000..4177114 --- /dev/null +++ b/src/client/js/card/card-base.html @@ -0,0 +1,7 @@ +
+ + +
    +
    diff --git a/src/client/js/card/card-input.html b/src/client/js/card/card-input.html new file mode 100644 index 0000000..3c778a6 --- /dev/null +++ b/src/client/js/card/card-input.html @@ -0,0 +1,4 @@ +
    + + +
    diff --git a/src/client/js/card/card-input.js b/src/client/js/card/card-input.js new file mode 100644 index 0000000..3e9812e --- /dev/null +++ b/src/client/js/card/card-input.js @@ -0,0 +1,163 @@ +var TitledCard = require('./card-titled.js'); +var inputHTML = require('./card-input.html'); + + +var InputCard = function(appendTo, options) { + TitledCard.call(this, appendTo, options); + + // Private variables + var self = this; + var innerBase = $(inputHTML); + + // Public Variables + + //***** Private Methods *****// + /** + * Gets the jQuery UI autocomplete config. + * + * @param {string} append The string to put after a selection is made. + * @param {string} prepend The string to put before the selection. + * @param {number} maxResults The maximum number of results displayed when filtering results. + * @param {string} trigger A string to watch for that triggers selection. + * @param {Array} results The results to filter for autocomplete. + * @return {object} A configuration object used by jQuery UI. + */ + var getAutocompleteConfig = function(append, prepend, maxResults, trigger, results) { + return { + minLength: 0, + source: function(request, response) { + if (trigger === undefined) { + response($.ui.autocomplete.filter(results, request.term.split(/,\s*/).pop()).slice(0, maxResults)); + } + else { + var last = request.term.split(trigger).pop(); + + // Fixes weird bug that doesn't force the DDL to hide if you trigger it with nothing. + if (trigger !== '' && request.term === '') { + response(''); + } + else { + // delegate back to autocomplete, but extract the last term + response($.ui.autocomplete.filter(results, last).slice(0, maxResults)); + } + } + }, + focus: function() { + return false; + }, + select: function(event, ui) { + if (trigger === undefined) { + var terms = this.value.split(/,\s*/); + terms.pop(); + terms.push(prepend); + terms.push(ui.item.value); + terms.push(append); + this.value = terms.join(''); + } + else { + var terms = [this.value.substring(0, this.value.lastIndexOf(trigger))]; + + terms.push(prepend); + terms.push(ui.item.value); + terms.push(append); + this.value = terms.join(''); + } + + // We have to manually mark the text field as dirty. If we don't, MDL text fields act weird. + $(this).parent().addClass('is-dirty'); + + return false; + } + }; + }; + + + //***** Privileged Methods *****// + /** + * Sets autocomplete bsaed upon some options. + * + * @param {object} options The options to set up autocomplete. + */ + this.setAutocomplete = function(options) { + var append = ''; + var prepend = ''; + var maxResults; + var trigger; + var results = []; + var input = innerBase.find('input'); + + if (options.trigger !== undefined) { + trigger = options.trigger; + } + if (options.append !== undefined) { + append = options.append; + } + if (options.prepend !== undefined) { + prepend = options.prepend; + } + if (options.maxResults !== undefined) { + maxResults = options.maxResults; + } + if (options.triggerOnFocus === true) { + input.on('focus', function() {input.autocomplete('search', self.getValue())}); + } + if (options.results !== undefined) { + results = options.results; + } + + input.autocomplete(getAutocompleteConfig(append, prepend, maxResults, trigger, results)); + }; + + /** + * Gets the value in the input. + * + * @return {string} The value in the input. + */ + this.getValue = function() { + return innerBase.find('input').val(); + }; + + /** + * Sets the value of the input. + * + * @param {string} value The value to set in the input. + */ + this.setValue = function(value) { + innerBase.find('input').val(value); + innerBase.addClass('is-dirty'); + }; + + /** + * Sets the label shown in the input when nothing has been typed. + * + * @param {string} label The value to set as the label. + */ + this.setLabel = function(label) { + innerBase.find('label').text(label); + }; + + // constructor + this.append(innerBase); + + if (options !== undefined) { + if (options.label !== undefined) { + this.setLabel(options.label); + } + if (options.autocomplete !== undefined) { + this.setAutocomplete(options.autocomplete); + } + } + + componentHandler.upgradeElement(innerBase[0], 'MaterialTextfield'); +}; + + +/** */ +InputCard.prototype.constructor = InputCard; +InputCard.prototype = Object.create(TitledCard.prototype); + +//***** Public Methods *****// + + +/** */ +module.exports = InputCard; diff --git a/src/client/js/card/card-textarea.html b/src/client/js/card/card-textarea.html new file mode 100644 index 0000000..8790ccd --- /dev/null +++ b/src/client/js/card/card-textarea.html @@ -0,0 +1,4 @@ +
    + + +
    diff --git a/src/client/js/card/card-textarea.js b/src/client/js/card/card-textarea.js new file mode 100644 index 0000000..52bb6a7 --- /dev/null +++ b/src/client/js/card/card-textarea.js @@ -0,0 +1,164 @@ +var TitledCard = require('./card-titled.js'); +var textareaHTML = require('./card-textarea.html'); + + + +var TextareaCard = function(appendTo, options) { + TitledCard.call(this, appendTo, options); + + // Private variables + var self = this; + var innerBase = $(textareaHTML); + + // Public Variables + + //***** Private Methods *****// + /** + * Gets the jQuery UI autocomplete config. + * + * @param {string} append The string to put after a selection is made. + * @param {string} prepend The string to put before the selection. + * @param {number} maxResults The maximum number of results displayed when filtering results. + * @param {string} trigger A string to watch for that triggers selection. + * @param {Array} results The results to filter for autocomplete. + * @return {object} A configuration object used by jQuery UI. + */ + var getAutocompleteConfig = function(append, prepend, maxResults, trigger, results) { + return { + minLength: 0, + source: function(request, response) { + if (trigger === undefined) { + response($.ui.autocomplete.filter(results, request.term.split(/,\s*/).pop()).slice(0, maxResults)); + } + else { + var last = request.term.split(trigger).pop(); + + // Fixes weird bug that doesn't force the DDL to hide if you trigger it with nothing. + if (trigger !== '' && request.term === '') { + response(''); + } + else { + // delegate back to autocomplete, but extract the last term + response($.ui.autocomplete.filter(results, last).slice(0, maxResults)); + } + } + }, + focus: function() { + return false; + }, + select: function(event, ui) { + if (trigger === undefined) { + var terms = this.value.split(/,\s*/); + terms.pop(); + terms.push(prepend); + terms.push(ui.item.value); + terms.push(append); + this.value = terms.join(''); + } + else { + var terms = [this.value.substring(0, this.value.lastIndexOf(trigger))]; + + terms.push(prepend); + terms.push(ui.item.value); + terms.push(append); + this.value = terms.join(''); + } + + // We have to manually mark the text field as dirty. If we don't, MDL text fields act weird. + $(this).parent().addClass('is-dirty'); + + return false; + } + }; + }; + + + //***** Privileged Methods *****// + /** + * Sets autocomplete bsaed upon some options. + * + * @param {object} options The options to set up autocomplete. + */ + this.setAutocomplete = function(options) { + var append = ''; + var prepend = ''; + var maxResults; + var trigger; + var results = []; + var input = innerBase.find('textarea'); + + if (options.trigger !== undefined) { + trigger = options.trigger; + } + if (options.append !== undefined) { + append = options.append; + } + if (options.prepend !== undefined) { + prepend = options.prepend; + } + if (options.maxResults !== undefined) { + maxResults = options.maxResults; + } + if (options.triggerOnFocus === true) { + input.on('focus', function() {input.autocomplete('search', self.getValue())}); + } + if (options.results !== undefined) { + results = options.results; + } + + input.autocomplete(getAutocompleteConfig(append, prepend, maxResults, trigger, results)); + }; + + /** + * Gets the value in the input. + * + * @return {string} The value in the input. + */ + this.getValue = function() { + return innerBase.find('textarea').val(); + }; + + /** + * Sets the value of the input. + * + * @param {string} value The value to set in the input. + */ + this.setValue = function(value) { + innerBase.find('textarea').val(value); + innerBase.addClass('is-dirty'); + }; + + /** + * Sets the label shown in the input when nothing has been typed. + * + * @param {string} label The value to set as the label. + */ + this.setLabel = function(label) { + innerBase.find('label').text(label); + }; + + // constructor + this.append(innerBase); + + if (options !== undefined) { + if (options.label !== undefined) { + this.setLabel(options.label); + } + if (options.autocomplete !== undefined) { + this.setAutocomplete(options.autocomplete); + } + } + + componentHandler.upgradeElement(innerBase[0], 'MaterialTextfield'); +}; + + +/** */ +TextareaCard.prototype.constructor = TextareaCard; +TextareaCard.prototype = Object.create(TitledCard.prototype); + +//***** Public Methods *****// + + +/** */ +module.exports = TextareaCard; diff --git a/src/client/js/card/card-titled.html b/src/client/js/card/card-titled.html new file mode 100644 index 0000000..10cb2b5 --- /dev/null +++ b/src/client/js/card/card-titled.html @@ -0,0 +1,3 @@ +

    A Header

    +

    +

    diff --git a/src/client/js/card/card-titled.js b/src/client/js/card/card-titled.js new file mode 100644 index 0000000..8fd60c2 --- /dev/null +++ b/src/client/js/card/card-titled.js @@ -0,0 +1,60 @@ +var Card = require('./card.js'); + +var titleHTML = require('./card-titled.html'); + +var TitledCard = function(appendTo, options) { + Card.call(this, appendTo, options); + + // Private variables + var self = this; + var innerBase = $(titleHTML); + + // Public Variables + + + //***** Private Methods *****// + + + //***** Privileged Methods *****// + + /** + * Sets the title that is displayed for this Card. + * + * @param {string} title The title of the Card. + */ + this.setTitle = function(title) { + innerBase.filter('h4').text(title); + }; + + /** + * This sets the help that is displayed for this card. + * + * @param {string} help The help to be displayed to users about this Card. + */ + this.setHelp = function(help) { + innerBase.filter('.help').text(help); + }; + + // constructor + this.insert(innerBase, 0); + + if (options !== undefined) { + if (options.title !== undefined) { + this.setTitle(options.title); + } + if (options.help !== undefined) { + this.setHelp(options.help); + } + } +}; + + +/** */ +TitledCard.prototype.constructor = TitledCard; +TitledCard.prototype = Object.create(Card.prototype); + +//***** Public Methods *****// + + +/** */ +module.exports = TitledCard; diff --git a/src/client/js/card/card.js b/src/client/js/card/card.js new file mode 100644 index 0000000..482e695 --- /dev/null +++ b/src/client/js/card/card.js @@ -0,0 +1,170 @@ + +var baseHTML = require('./card-base.html'); + +var Card = function(appendTo, options) { + // Private variables + var self = this; + var base = $(baseHTML); + + // Public Variables + + //***** Private Methods *****// + + + //***** Privileged Methods *****// + + /** + * Inserts HTML content at the given index. + * + * @param {string} content An HTML string to insert into the card. + * @param {number} index The index to insert the content at. + */ + this.insert = function(content, index) { + if (index === 0) { + base.prepend($(content)); + } + else { + $(content).insertBefore(base.children()[index]); + } + }; + + /** + * Appends content to the end of the base div (as the last child). + * + * @param {string} content An HTML string to append to base. + */ + this.append = function(content) { + base.append($(content)); + }; + + /** + * Removes this card. + * + */ + this.remove = function() { + base.remove(); + }; + + /** + * Shows the Card. Also triggers the event card.show. + * + * @this Card + */ + this.show = function() { + if (base.hasClass('hidden') === true) { + base.removeClass('hidden'); + base.trigger('card.show', this); + } + }; + + /** + * Hides the card. Also triggers the event card.hide. + * + * @this Card + */ + this.hide = function() { + if (base.hasClass('hidden') === false) { + base.addClass('hidden'); + base.trigger('card.hide', this); + } + }; + + /** + * Determines whether the Card is visible or not. + * + * @return {Boolean} True for shown, false for hidden. + */ + this.isShown = function() { + return base.hasClass('hidden') === false; + }; + + /** + * Attaches an event handler to this card. + * + * @param {string} name The event to watch for. + * @param {function} toExecute The callback to execute when the event is fired. + */ + this.attachEvent = function(name, toExecute) { + base.on(name, toExecute); + }; + + /** + * Adds an option to the Card. This can be used to trigger functions related to that Card. + * + * @param {String} title The text to be displayed in the menu. + * @param {Function} callback The function to call when the menu item is clicked. + * @param {String | undefined} icon The icon to display next to the title. Leave undefined for no icon. + */ + this.addOption = function(title, callback, icon) { + var menu = base.find('ul'); + menu.append('
  • ' + title + '
  • '); + + var item = menu.children().filter(function() { + return $(this).text() === title; + }); + + item.on('click', callback); + + var button = base.find('button'); + button.removeClass('hidden'); + }; + + /** + * Removes an option from the Card. + * + * @param {String} title The title of the option to be displayed. + */ + this.removeOption = function(title) { + var menu = base.find('ul'); + + var item = menu.children().filter(function() { + return $(this).text() === title; + }); + + item.off(); + item.remove(); + + if (menu.children().length === 0) { + var button = base.find('button'); + button.addClass('hidden'); + } + }; + + // constructor + appendTo.append(base); + + // Create a unique ID for binding the menu to the button. + // From here: https://gist.github.com/gordonbrander/2230317 + var id = 'UID_' + Math.random().toString(36).substr(2, 9); + var menu = base.find('ul'); + var button = base.find('button'); + + button.attr('id', id); + menu.attr('data-mdl-for', id); + + if (options !== undefined) { + if (options.visible !== undefined) { + if (options.visible === true) { + this.show(); + } + else { + this.hide(); + } + } + if (options.paragraphs !== undefined) { + options.paragraphs.every(function(data) { + self.append('

    ' + data + '

    '); + return true; + }); + } + } + + this.hide(); + componentHandler.upgradeElement(base.find('.mdl-js-menu')[0], 'MaterialMenu'); +}; + +//***** Public Methods *****// + + +/** */ +module.exports = Card; diff --git a/src/client/js/cards-handler.js b/src/client/js/cards-handler.js new file mode 100644 index 0000000..bc2df24 --- /dev/null +++ b/src/client/js/cards-handler.js @@ -0,0 +1,606 @@ +'use strict'; + +var InputCard = require('./card/card-input.js'); +var TitledCard = require('./card/card-titled.js'); +var TextareaCard = require('./card/card-textarea.js'); +var List = require('./list/list.js'); +var EmailRule = require('./data/email-rule.js'); +var RuleTypes = require('./data/rule-types.js'); +var Database = require('./data/database.js'); +var Util = require('./util.js'); +var PubSub = require('pubsub-js'); +var Keys = require('./data/prop-keys.js'); +var CardsConfig = require('./cards/cards-config.js'); +var CardNames = require('./cards/card-names.js'); + +var Cards = function(parent) { + + //***** LOCAL VARIABLES *****// + + var self = this; + + // This handles all object reading/writing + var database = new Database(); + + // This holds all the "cards". + var contentArea = parent; + + // All the different sheet names. + var sheets = ['Loading...']; + + // The maximum number of results to display in the autocompletes. + var maxResults = 5; + + // The list containing all the Cards. + var cards = new List(); + + // This stores the configured Cards. These are only meant for easily adding/removing Cards without losing the data. + var cardRepository = CardsConfig.buildCardRepo(); + + // Whether to show the Card help or not + var showingHelp; + + // The currently shown Card. + var activeCard; + + // The rule to update. This is only used when an existing EmailRule is being updated. + var updateRule = null; + + //***** PUBLIC *****// + + /** + * Performs basic set up of the Mailman environment. + * + * @constructor + * @param {jQuery} contentArea The area where Cards are meant to be added. + */ + this.init = function(contentArea) { + + // Set the help any time a new Card is inserted. + PubSub.subscribe('Cards.insertNode', function(msg, data) { + setHelp(); + }); + + setupCards(); + + // Load information from GAS + if (window.google !== undefined) { + google.script.run + .withSuccessHandler(setSheets) + .getSheets(); + } + }; + + /** + * Resets the view so it can handle a new EmailRule. + * + */ + this.cleanup = function() { + + for (var property in cardRepository) { + + if (cardRepository.hasOwnProperty(property) && cardRepository[property].setValue) { + cardRepository[property].setValue(''); + } + if (cardRepository.hasOwnProperty(property) && cardRepository[property].hide) { + cardRepository[property].hide(); + } + } + + updateRule = null; + }; + + /** + * This function goes to the next card. + * + * @return {?Node} The new active node. + */ + this.next = function() { + if (activeCard.next === null) { + return null; + } + + activeCard.data.hide(); + activeCard = activeCard.next; + activeCard.data.show(); + + return activeCard; + }; + + /** + * This function goes to the previous card. + * + * @return {?Node} The new active node. + */ + this.back = function() { + if (activeCard.previous === null) { + return; + } + + activeCard.data.hide(); + activeCard = activeCard.previous; + activeCard.data.show(); + + return activeCard; + }; + + /** + * Returns the Node of the active Card. + * + * @return {Node} The active Node. + */ + this.getActiveNode = function() { + return activeCard; + }; + + /** + * Checks to see if the card handler is at the first Card. + * + * @return {Boolean} true if the active Card is the first Card. + */ + this.isFirst = function() { + return activeCard === cards.head; + }; + + /** + * Checks to see if the card handler is at the last Card. + * + * @return {Boolean} true if the active Card is the last Card. + */ + this.isLast = function() { + return activeCard === cards.tail; + }; + + /** + * Gets the Card with the given name. + * + * @param {String} name The unique id of the Card to return. + * @return {Card} The Card with the given id. + */ + this.getCard = function(name) { + return cardRepository[name]; + }; + + /** + * Makes the Card with the given id the active Card. Invokes the pubsub message Cards.jumpTo. + * + * @param {String} cardId The ID of the Card. + */ + this.jumpTo = function(cardId) { + hideAll(); + + var node = cards.head; + while (node !== null) { + + if (node.name === cardId) { + node.data.show(); + activeCard = node; + PubSub.publish('Cards.jumpTo', cardId); + + return; + } + + node = node.next; + } + }; + + /** + * This function toggles the state of the help

    tags. + * + */ + this.toggleHelp = function() { + if (showingHelp) { + hideHelp(); + } + else { + showHelp(); + } + }; + + /** + * Sets all Card values based upon the values in emailRule. + * TODO Replace this with something more flexible. + * + * @param {EmailRule} rule The EmailRule to set the Cards to. This is used for editing an existing EmailRule. + */ + this.setRule = function(rule) { + updateRule = rule; + + if (rule.sheet) { + cardRepository[CardNames.sheet].setValue(rule.sheet); + } + if (rule.headerRow) { + cardRepository[CardNames.row].setValue(rule.headerRow); + } + if (rule.to) { + cardRepository[CardNames.to].setValue(rule.to); + } + if (rule.subject) { + cardRepository[CardNames.subject].setValue(rule.subject); + } + if (rule.body) { + cardRepository[CardNames.body].setValue(rule.body); + } + if (rule.sendColumn) { + cardRepository[CardNames.shouldSend].setValue(rule.sendColumn); + } + if (rule.timestampColumn) { + cardRepository[CardNames.lastSent].setValue(rule.timestampColumn); + } + + self.setType(rule.ruleType); + }; + + /** + * This is used to prepare the CardsHandler for a new INSTANT or TRIGGER EmailRule creation. + * + * @param {RuleTypes} type One of the EmailRule RuleTypes. + */ + this.setType = function(type) { + if (type === RuleTypes.INSTANT) { + cards = createInstantList(); + } + else if (type === RuleTypes.TRIGGER) { + cards = createTriggerList(); + } + + hideHelp(); + activeCard = cards.head; + self.jumpTo(activeCard.name); + }; + + /** + * Gets the active rules email type. + * + * @return {String} The current ruleType. + */ + this.getRuleType = function() { + var trigger = self.getCard(CardNames.triggerSetup); + + var current = cards.head; + while (current !== null) { + if (current.data === trigger) { + return RuleTypes.TRIGGER; + } + + current = current.next; + } + + return RuleTypes.INSTANT; + }; + + /** + * Gets an EmailRule associated with this series of Cards. The interaction of the update/create portion + * makes this way more confusing than it need be. + * + * TODO Refactor this. The multiple returns makes flow confusing. It still sucks. + * @return {EmailRule} Created from the various Cards this handler is supervising. + */ + this.getRule = function() { + var config = {}; + if (updateRule !== null) { + config = updateRule; + } + + config.to = self.getCard(CardNames.to).getValue(); + config.subject = self.getCard(CardNames.subject).getValue(); + config.body = self.getCard(CardNames.body).getValue(); + config.sheet = self.getCard(CardNames.sheet).getValue(); + if (self.getRuleType() === RuleTypes.TRIGGER) { + config.sendColumn = self.getCard(CardNames.shouldSend).getValue(); + config.timestampColumn = self.getCard(CardNames.lastSent).getValue(); + config.ruleType = RuleTypes.TRIGGER; + } + else { + config.ruleType = RuleTypes.INSTANT; + } + + if (self.getCard(CardNames.row).getValue() !== '') { + config.headerRow = self.getCard(CardNames.row).getValue(); + } + + if (updateRule !== null) { + return updateRule; + } + + return new EmailRule(config); + }; + + //***** PRIVATE *****// + + /** + * Sets the event handlers on the various Cards. + * + * @private + */ + var setupCards = function() { + cardRepository[CardNames.sheet].attachEvent('card.hide', function(event) { + var sheet = cardRepository[CardNames.sheet].getValue(); + + if (sheet !== '') { + var row = '1'; + if (updateRule) { + row = updateRule.headerRow; + } + + google.script.run + .withSuccessHandler(setColumns) + .getHeaderStrings({ + sheet: sheet, + headerRow: row + }); + } + }); + + cardRepository[CardNames.to].addOption('change header row', changeHeaderRow); + + cardRepository[CardNames.row].attachEvent('card.hide', function(event, card) { + + // Set the header row + if (window.google !== undefined) { + var row = card.getValue(); + var sheet = cardRepository[CardNames.sheet].getValue(); + + // Verify the row data. + var numTest = parseInt(row); + if (!isNaN(numTest) && numTest > 0) { + google.script.run + .withSuccessHandler(setColumns) + .getHeaderStrings({ + sheet: sheet, + headerRow: row + }); + } + } + + }); + }; + + var changeHeaderRow = function(e) { + // Add another card before this one, but after Sheet + + if (getNode(CardNames.row) === null) { + var headerNode = insertNode(CardNames.sheet, cardRepository[CardNames.row]); + headerNode.name = CardNames.row; + } + + self.jumpTo(CardNames.row); + + // Remove the option + //cardRepository[CardNames.to].removeOption('change header row'); + }; + + /** + * This function hides the help

    tags. + * + * @private + */ + var hideHelp = function() { + showingHelp = false; + Util.setHidden($('.help'), true); + }; + + /** + * This function shows the help

    tags. + * + * @private + */ + var showHelp = function() { + showingHelp = true; + Util.setHidden($('.help'), false); + }; + + /** + * Based upon the value of showingHelp, it either hides or shows the help. + * + * @private + */ + var setHelp = function() { + if (showingHelp) { + showHelp(); + } + else { + hideHelp(); + } + }; + + /** + * Gets the node with a given name. It's worth noting that the name of a node isn't something + * inherant to the Node object. This file adds them as an alternative way of discovery. + * + * @private + * @param {string} name The name of the Node. + * @return {Node} The node with the given name. + */ + var getNode = function(name) { + var current = cards.head; + while (current !== null) { + if (current.name === name) { + return current; + } + + current = current.next; + } + + return null; + }; + + /** + * Inserts a Node after the Node with the given name. + * Note: This cannot be used to insert a Node at the start of a list. + * + * @private + * @param {String} before The name of the Node before the Node to be inserted. + * @param {Card} card The Card to be inserted. + * @return {Node} The newly inserted Node. + */ + var insertNode = function(before, card) { + var beforeNode = getNode(before); + + if (beforeNode === null) { + return null; + } + + var index = cards.getPosition(beforeNode); + var node = cards.insert(index + 1, card); + + PubSub.publish('Cards.insertNode', node); + + return node; + }; + + /** + * Hides all cards. + * + * @private + */ + var hideAll = function() { + var node = cards.head; + + while (node !== null) { + + node.data.hide(); + node = node.next; + } + }; + + /** + * Sets the value of the sheets autocomplete. + * + * @private + * @param {Array} values The values to display in the Sheet Card. + */ + var setSheets = function(values) { + sheets = values; + cardRepository[CardNames.sheet].setAutocomplete({ + results: sheets, + maxResults: maxResults, + triggerOnFocus: true + }); + }; + + /** + * Sets the autocomplete drop down values to the provided values. + * + * @private + * @param {Array} values The values to use for autocomplete. + */ + var setColumns = function(values) { + cardRepository[CardNames.to].setAutocomplete({ + results: values, + prepend: '<<', + append: '>>', + maxResults: maxResults, + triggerOnFocus: true + }); + + cardRepository[CardNames.subject].setAutocomplete({ + results: values, + trigger: '<<', + prepend: '<<', + append: '>>', + maxResults: maxResults + }); + + cardRepository[CardNames.body].setAutocomplete({ + results: values, + trigger: '<<', + prepend: '<<', + append: '>>', + maxResults: maxResults + }); + + cardRepository[CardNames.shouldSend].setAutocomplete({ + results: values, + prepend: '<<', + append: '>>', + maxResults: maxResults, + triggerOnFocus: true + }); + + cardRepository[CardNames.lastSent].setAutocomplete({ + results: values, + prepend: '<<', + append: '>>', + maxResults: maxResults, + triggerOnFocus: true + }); + }; + + /** + * Creates the Card flow for Instant emails. + * + * @private + * @return {List} A List of Cards that can be used to create an INSTANT EmailRule. + */ + var createInstantList = function() { + var list = new List(); + + list.add(cardRepository[CardNames.welcome]); + list.tail.name = CardNames.welcome; + + list.add(cardRepository[CardNames.sheet]); + list.tail.name = CardNames.sheet; + + list.add(cardRepository[CardNames.to]); + list.tail.name = CardNames.to; + + list.add(cardRepository[CardNames.subject]); + list.tail.name = CardNames.subject; + + list.add(cardRepository[CardNames.body]); + list.tail.name = CardNames.body; + + list.add(cardRepository[CardNames.sendNow]); + list.tail.name = CardNames.sendNow; + + return list; + }; + + /** + * Creates the Card flow for TRIGGER emails. + * + * @private + * @return {List} A List of Cards that can be used to create a TRIGGER EmailRule. + */ + var createTriggerList = function() { + var list = new List(); + + list.add(cardRepository[CardNames.welcome]); + list.tail.name = CardNames.welcome; + + list.add(cardRepository[CardNames.sheet]); + list.tail.name = CardNames.sheet; + + list.add(cardRepository[CardNames.to]); + list.tail.name = CardNames.to; + + list.add(cardRepository[CardNames.subject]); + list.tail.name = CardNames.subject; + + list.add(cardRepository[CardNames.body]); + list.tail.name = CardNames.body; + + list.add(cardRepository[CardNames.triggerSetup]); + list.tail.name = CardNames.triggerSetup; + + list.add(cardRepository[CardNames.shouldSend]); + list.tail.name = CardNames.shouldSend; + + list.add(cardRepository[CardNames.lastSent]); + list.tail.name = CardNames.lastSent; + + list.add(cardRepository[CardNames.triggerConfirmation]); + list.tail.name = CardNames.triggerConfirmation; + + return list; + }; + + this.init(contentArea); +}; + +/***** GAS Response Functions *****/ + + +/** */ +module.exports = Cards; diff --git a/src/client/js/cards/card-names.js b/src/client/js/cards/card-names.js new file mode 100644 index 0000000..5567d55 --- /dev/null +++ b/src/client/js/cards/card-names.js @@ -0,0 +1,20 @@ + + +/** + * Exports an Object containing all the Card names. + * + * @type {Object} + */ +module.exports = { + welcome: 'Welcome', + sheet: 'Sheet', + to: 'To', + row: 'Header Row', + subject: 'Subject', + body: 'Body', + sendNow: 'Email', + triggerConfirmation: 'Schedule', + triggerSetup: 'Trigger', + shouldSend: 'Confirmation', + lastSent: 'Last Sent' +}; diff --git a/src/client/js/cards/cards-config.js b/src/client/js/cards/cards-config.js new file mode 100644 index 0000000..8031511 --- /dev/null +++ b/src/client/js/cards/cards-config.js @@ -0,0 +1,119 @@ +var CardNames = require('./card-names.js'); +var InputCard = require('../card/card-input.js'); +var TitledCard = require('../card/card-titled.js'); +var TextareaCard = require('../card/card-textarea.js'); + +var CardsConfig = {}; + + +/** + * Initializes a Card repository. + * + * @return {Object} The repository used for storing Cards. These may not be in the program flow. + */ +CardsConfig.buildCardRepo = function() { + + var repo = {}; + + repo[CardNames.welcome] = new TitledCard(contentArea, { + title: 'Welcome!', + help: 'Help will be displayed here normally. Since this is just the welcome page, there isn\'t much to know!', + paragraphs: [ + 'Welcome to Mailman! This application helps users easily create mail merges. It aims to be easy to use, ' + + 'while also providing advanced options for power users.', + 'To get started, simply click NEXT down below.' + ] + }); + + repo[CardNames.sheet] = new InputCard(contentArea, { + title: 'Which Sheet are we sending from?', + help: 'This Sheet must contain all the information you may want to send in an email.', + label: 'Sheet...' + }); + + repo[CardNames.to] = new InputCard(contentArea, { + title: 'Who are you sending to?', + help: 'This is the column filled with the email addresses of the recipients.', + label: 'To...' + }); + + repo[CardNames.row] = new InputCard(contentArea, { + title: 'Which row contains your header titles?', + help: 'By default, Mailman looks in row 1 for your header titles.' + + ' If your header is not in row 1, please input the row.', + label: 'Header row...' + }); + + repo[CardNames.subject] = new InputCard(contentArea, { + title: 'What\'s your subject?', + help: 'Recipients will see this as the subject line of the email. Type "<<" to see a list of column names. ' + + 'These tags will be swapped out with the associated values in the Sheet.', + label: 'Subject...' + }); + + repo[CardNames.body] = new TextareaCard(contentArea, { + title: 'What\'s in the body?', + help: 'Recipients will see this as the body of the email. Type "<<" to see a list of column names. These tags ' + + 'will be swapped out with the associated values in the Sheet.', + label: 'Body...' + }); + + repo[CardNames.sendNow] = new TitledCard(contentArea, { + title: 'Send emails now?', + paragraphs: [ + 'This will send out an email blast right now. ' + + 'If you\'d like, you can send the emails at a later time, or even based upon a value in a given column. ' + + 'Just select the related option from the bottom right.' + ] + }); + + repo[CardNames.triggerSetup] = new TitledCard(contentArea, { + title: 'Repeated emails.', + paragraphs: [ + 'Mailman will now guide you through the process of creating your own repeated mail merge.', + 'This feature can be used to set up an email-based reminder system.' + ], + help: 'If you\'d like to go back to a regular mail merge, use the options below.' + }); + + repo[CardNames.shouldSend] = new InputCard(contentArea, { + title: 'Which column determines whether an email should be sent?', + paragraphs: [ + 'Mailman regularly checks whether an email needs to be sent. ' + + 'Please specify a column that determines when an email should be sent.', + 'Note that Mailman looks for the value TRUE to determine when to send an email.' + ], + help: 'Mailman checks roughly every 15 minutes for new emails to send. ' + + 'Keep in mind, this can lead to sending emails to someone every 15 minutes. ' + + 'Continue on for some ideas about how to avoid this!', + label: 'Send?' + }); + + repo[CardNames.lastSent] = new InputCard(contentArea, { + title: 'Where should Mailman keep track of the previously sent email?', + paragraphs: [ + 'Every time Mailman sends an email, it records the time in a cell.', + 'Using the timestamp, you can determine whether you want to send another email.' + ], + help: 'This timestamp can be used for some interesting things! ' + + 'Imagine you are interested in sending an email to someone every day (just to annoy them). ' + + 'You could just set the formula in the previously mentioned column to ' + + '"=TODAY() - {put the last sent here} > 1". Now an email will be sent every time TRUE pops up (every day).', + label: 'Last sent...' + }); + + repo[CardNames.triggerConfirmation] = new TitledCard(contentArea, { + title: 'Submit the trigger?', + paragraphs: [ + 'This will regularly check the previously mentioned column for the value TRUE. ' + + 'When TRUE is found in the column, an email is sent out with that row\'s information. ', + 'If you\'d like to send now, just select the related option from the bottom right.' + ] + }); + + return repo; +}; + + +/** */ +module.exports = CardsConfig; diff --git a/src/client/js/client.js b/src/client/js/client.js index 9565501..a5f35be 100644 --- a/src/client/js/client.js +++ b/src/client/js/client.js @@ -4,5 +4,4 @@ var Util = require('./util.js'); $(document).ready(function() { var mailman = new MailMan(); - mailman.init(); }); diff --git a/src/client/js/data/database.js b/src/client/js/data/database.js new file mode 100644 index 0000000..c74e7c9 --- /dev/null +++ b/src/client/js/data/database.js @@ -0,0 +1,98 @@ + +var Database = function() { + // private variables + + // public variables + + // ***** private methods ***** // + + /** + * Converts the returned String into an Object. If the string isn't valid JSON, an error is thrown. + * + * @param {string} jsonString The stringified data value to be converted into an object. + * @param {Object} callback The object that stores the success and failure handlers. + * @param {Function} callback.success The function to call after parsing is successful. + * @param {Function} callback.failure The function to call after parsing fails. + */ + var handleJSONParsing = function(jsonString, callback) { + if (jsonString === undefined) { + if (callback.failure) { + callback.failure(); + } + + return; + } + + var obj; + + try { + obj = JSON.parse(jsonString); + } + catch (e) { + console.log('Failed when parsing JSON: ' + jsonString); + if (callback.failure) { + callback.failure(); + } + else { + throw e; + } + } + + callback.success(obj); + }; + + // ***** privileged methods ***** // + + /** + * Saves the given object to the Mailman file store. + * + * @param {String} key The unique key to save the Object under. + * If the key already exists, the old object is overwritten. + * @param {Object} obj A JSON-serializeable Object. + * @param {Function} success This function is called on success. + */ + this.save = function(key, obj, success) { + var jsonString = JSON.stringify(obj); + + if (success === undefined) { + google.script.run + .save(key, jsonString); + } + else { + google.script.run + .withSuccessHandler(success) + .save(key, jsonString); + } + }; + + /** + * Loads an object. + * + * @param {String} key The key of the Object to return. + * @param {Function} callback The function to call on success. + * @param {Function} failure The function to call on failure. + */ + this.load = function(key, callback, failure) { + var cbObject; + if (failure !== undefined) { + cbObject = { + success: callback, + failure: failure + }; + } + else { + cbObject = { + success: callback + }; + } + + google.script.run + .withUserObject(cbObject) + .withSuccessHandler(handleJSONParsing) + .load(key); + }; +}; + + +/** */ +module.exports = Database; diff --git a/src/client/js/data/email-rule.js b/src/client/js/data/email-rule.js new file mode 100644 index 0000000..0073a15 --- /dev/null +++ b/src/client/js/data/email-rule.js @@ -0,0 +1,116 @@ +'use strict'; + +var ID = require('./id.js'); +var RuleTypes = require('./rule-types.js'); + + + +/** + * This resource represents an email blast. They can be one time, or recurring (TRIGGER) + * emails. + * + * @constructor + * @param {Object} config The config Object used to initialize this EmailRule. + * @param {string} config.ruleType The type of EmailRule. Can be INSTANT (one time) or TRIGGER (reoccurring). + * @param {string} config.to The tagged column that contains the username of the recipient. + * @param {string} config.sheet The name of the Sheet that contains all the needed columns. + * Mailman requires all columns to be in the same sheet. + * @param {string} config.subject The tagged subject of the EmailRule. This can contain tags and normal strings. + * @param {string} config.body The tagged body of the EmailRule. It can contain text and tags together. + * TODO support HTML. + * @param {string} config.sendColumn The tagged column that contains the truthy value. + * @param {string} config.timestampColumn The tagged column that Mailman will edit when an email is sent. + */ +var EmailRule = function(config) { + if (config.ruleType == null) { + throw new Error('EmailRule config is missing "ruleType".'); + } + if (config.to == null) { + throw new Error('EmailRule config is missing "to".'); + } + if (config.sheet == null) { + throw new Error('EmailRule config is missing "sheet".'); + } + if (config.subject == null) { + throw new Error('EmailRule config is missing "subject".'); + } + if (config.body == null) { + throw new Error('EmailRule config is missing "body".'); + } + if (config.ruleType === RuleTypes.TRIGGER && + config.sendColumn == null) { + throw new Error('EmailRule config is missing "sendColumn".'); + } + if (config.ruleType === RuleTypes.TRIGGER && + config.timestampColumn == null) { + throw new Error('EmailRule config is missing "timestampColumn".'); + } + + // private variables + + // This id is only used client-side. It allows each rule to be distinguished from the next. + var id = ID(); + var self = this; + + // public variables + this.ruleType = config.ruleType; + this.headerRow = config.headerRow == null ? '1' : config.headerRow.toString(); + + // INSTANT + this.to = config.to; + this.sheet = config.sheet; + this.subject = config.subject; + this.body = config.body; + + // TRIGGER + this.sendColumn = config.sendColumn; + this.timestampColumn = config.timestampColumn; + + // ***** private methods ***** // + + + // ***** privileged methods ***** // + + /** + * Checks whether a given email object is equal to either the supplied object or the supplied id. + * + * @param {EmailRule} rule Either the id of the EmailRule or the EmailRule. + * @return {boolean} True if the given value is equal to this EmailRule. + */ + this.isEqual = function(rule) { + return rule.getID() === id; + }; + + /** + * Returns the unique id of this EmailRule. + * + * @return {string} The unique id of this EmailRule. + */ + this.getID = function() { + return id; + }; + + /** + * Converts this EmailRule to an easily serializeable form. + * + * @return {Object} The configuration object, which can be used to rebuild this object exactly. + * See EmailRule for a detailed description of all object members. + */ + this.toConfig = function() { + return { + ruleType: self.ruleType, + headerRow: self.headerRow, + to: self.to, + sheet: self.sheet, + subject: self.subject, + body: self.body, + sendColumn: self.sendColumn, + timestampColumn: self.timestampColumn + }; + }; + +}; + + +/** */ +module.exports = EmailRule; diff --git a/src/client/js/data/id.js b/src/client/js/data/id.js new file mode 100644 index 0000000..3cb1dba --- /dev/null +++ b/src/client/js/data/id.js @@ -0,0 +1,17 @@ +/** + * Thanks to gordonbrander for the implementation. Simple and effective. + * See: https://gist.github.com/gordonbrander/2230317 + * Useful for less than 10000 ids. Beyond that, collisions start to occur. + * + * @return {string} A randomized string. + */ +var ID = function() { + // Math.random should be unique because of its seeding algorithm. + // Convert it to base 36 (numbers + letters), and grab the first 9 characters + // after the decimal. + return '_' + Math.random().toString(36).substr(2, 9); +}; + + +/** */ +module.exports = ID; diff --git a/src/client/js/data/prop-keys.js b/src/client/js/data/prop-keys.js new file mode 100644 index 0000000..1fff03f --- /dev/null +++ b/src/client/js/data/prop-keys.js @@ -0,0 +1,7 @@ +var Keys = { + RULE_KEY: 'MAILMAN_PROP_RULES' +}; + + +/** */ +module.exports = Keys; diff --git a/src/client/js/data/rule-container.js b/src/client/js/data/rule-container.js new file mode 100644 index 0000000..1a4ebb6 --- /dev/null +++ b/src/client/js/data/rule-container.js @@ -0,0 +1,148 @@ +var EmailRule = require('./email-rule.js'); +var RuleTypes = require('./rule-types.js'); +var Database = require('./database.js'); +var PubSub = require('pubsub-js'); +var Keys = require('./prop-keys.js'); + + + +/** + * This model holds all EmailRules. It is built to make serialization and deserialization easy. + * + * @param {Object} config The Object used to rebuild the RuleContainer. + * @param {Array} config.rules The EmailRules that this container holds. + * @constructor + */ +var RuleContainer = function(config) { + + // private variables + var self = this; + var rules = []; + var database = new Database(); + + // public variables + + // ***** private methods ***** // + this.init_ = function(config) { + + if (config.rules != null) { + + var ruleObjs = config.rules; + for (var i = 0; i < ruleObjs.length; i++) { + + var ruleObj = ruleObjs[i]; + rules.push(new EmailRule(ruleObj)); + } + } + }; + + // ***** privileged methods ***** // + + /** + * Appends a new EmailRule. Notifies all listeners. + * + * TODO Make this take an EmailRule. + * @param {Object} config The config object for this EmailRule. Please see EmailRule for details. + */ + this.add = function(config) { + rules.push(new EmailRule(config)); + + database.save(Keys.RULE_KEY, self.toConfig(), function() { + PubSub.publish('Rules.add'); + }); + }; + + /** + * Removes an EmailRule from the container and notifies any listeners. + * + * @param {EmailRule} rule The rule to delete. + */ + this.remove = function(rule) { + rules.forEach(function(element, index, array) { + + if (element.isEqual(rule)) { + array.splice(index, 1); + + // Push rule update + database.save(Keys.RULE_KEY, self.toConfig(), function() { + PubSub.publish('Rules.delete'); + }); + + return; + } + }); + }; + + /** + * Updates the given EmailRule. Note that the id of the given EmailRule must be the same as the EmailRule stored + * in this RuleContainer. + * + * @param {EmailRule} rule The new EmailRule. It's id must be the same as an existing EmailRule, + * or the update will fail. + */ + this.update = function(rule) { + + var index = self.indexOf(rule.getID()); + if (index === -1) { + throw new Error('Error: EmailRule not found.'); + } + + rules[index] = rule; + database.save(Keys.RULE_KEY, self.toConfig(), function() { + PubSub.publish('Rules.update'); + }); + }; + + /** + * Gets an EmailRule by index. + * @param {number} index The index of the EmailRule. + * @return {EmailRule} The EmailRule at the given index. + */ + this.get = function(index) { + return rules[index]; + }; + + /** + * Gets the index of a given EmailRule by id. + * + * @param {string} id The id of the rule to find. + * @return {number} The index of the EmailRule with the given ID. -1 if not found. + */ + this.indexOf = function(id) { + return rules.findIndex(function(element) { + return element.getID() === id; + }); + }; + + /** + * Gets the number of EmailRules. + * + * @return {number} The number of EmailRules. + */ + this.length = function() { + return rules.length; + }; + + /** + * Converts this RuleContainer to a serializeable form. + * + * @return {Object} The configuration object, which can be used to rebuild this object exactly. + * See RuleContainer for a detailed description of all object members. + */ + this.toConfig = function() { + var ruleConfigs = []; + for (var i = 0; i < rules.length; i++) { + ruleConfigs.push(rules[i].toConfig()); + } + + return { + rules: ruleConfigs + }; + }; + + this.init_(config); +}; + + +/** */ +module.exports = RuleContainer; diff --git a/src/client/js/data/rule-types.js b/src/client/js/data/rule-types.js new file mode 100644 index 0000000..f6c46f8 --- /dev/null +++ b/src/client/js/data/rule-types.js @@ -0,0 +1,8 @@ +var RuleTypes = { + INSTANT: 'INSTANT', + TRIGGER: 'TRIGGER' +}; + + +/** */ +module.exports = RuleTypes; diff --git a/src/client/js/list/list.js b/src/client/js/list/list.js new file mode 100644 index 0000000..9b62c11 --- /dev/null +++ b/src/client/js/list/list.js @@ -0,0 +1,186 @@ +var Node = require('./node.js'); + + +// Used some of the code found here: +// https://code.tutsplus.com/articles/data-structures-with-javascript-singly-linked-list-and-doubly-linked-list--cms-23392 +// It's worth noting that there are several errors in the list implementation on this site, so beware. + + + +/** @constructor */ +var List = function() { + this._length = 0; + this.head = null; + this.tail = null; +}; + + +// ***** Public Functions ***** // +/** + * Adds a new node to the end of the List. + * + * @param {Object} value The data to be assigned to the node. + * @return {Node} The newly created node. + */ +List.prototype.add = function(value) { + var node = new Node(value); + + if (this._length !== 0) { + node.previous = this.tail; + this.tail.next = node; + this.tail = node; + } else { + this.head = node; + this.tail = node; + } + + this._length++; + + return node; +}; + + +/** + * Inserts a Node at the given index. + * + * @param {Number} index The index to insert the Node at. + * @param {Object} value the value of the new Node. + * @return {Node} The newly inserted Node. + */ +List.prototype.insert = function(index, value) { + var newNode = new Node(value); + + //Adding at the very end. + if (index === this._length) { + return this.add(value); + } + // Adding at the very start of the list + else if (index == 0) { + var oldNode = this.getNode(index); + + this.head = newNode; + + newNode.next = oldNode; + oldNode.previous = newNode; + } + else { + var oldNode = this.getNode(index); + var before = oldNode.previous; + + before.next = newNode; + newNode.previous = before; + newNode.next = oldNode; + oldNode.previous = newNode; + } + + this._length++; + + return newNode; +}; + + +/** + * Gets a node at a specified 0-based position. + * + * @param {Number} position The 0-based position of the node to find. + * @return {Node} The node at position. + */ +List.prototype.getNode = function(position) { + var currentNode = this.head; + var length = this._length; + var count = 0; + + // Verify the position is valid. + if (length === 0 || position < 0 || position >= length) { + throw new Error('Failure: node not in list.'); + } + + while (count < position) { + currentNode = currentNode.next; + count++; + } + + return currentNode; +}; + + +/** + * Gets the index of the supplied Node. + * + * @param {Node} node The Node to find the index of. + * @return {number} The index of the supplied Node. + */ +List.prototype.getPosition = function(node) { + var currentNode = this.head; + var position = -1; + + while (currentNode !== null) { + position++; + + if (currentNode === node) { + return position; + } + + currentNode = currentNode.next; + } + + return null; +}; + + +/** + * This function removes a node from the List. + * + * @param {Number} position The 0-based position of the node to remove. + */ +List.prototype.remove = function(position) { + var currentNode = this.head; + var length = this._length; + var count = 0; + var beforeNodeToDelete = null; + var nodeToDelete = null; + var afterNodeToDelete = null; + //var deletedNode = null; + + // 1st use-case: an invalid position + if (length === 0 || position < 0 || position >= length) { + throw new Error('Failure: non-existent node in this list.'); + } + + // 2nd use-case: the first node is removed + if (position === 0) { + this.head = currentNode.next; + + // 2nd use-case: there is a second node + if (this.head !== null) { + this.head.previous = null; + } + // 2nd use-case: there is no second node + else { + this.tail = null; + } + } +// 3rd use-case: the last node is removed + else if (position === this._length - 1) { + this.tail = this.tail.previous; + this.tail.next = null; + } +// 4th use-case: a middle node is removed + else { + currentNode = this.getNode(position); + + beforeNodeToDelete = currentNode.previous; + nodeToDelete = currentNode; + afterNodeToDelete = currentNode.next; + + beforeNodeToDelete.next = afterNodeToDelete; + afterNodeToDelete.previous = beforeNodeToDelete; + nodeToDelete = null; + } + + this._length--; +}; + + +/** */ +module.exports = List; diff --git a/src/client/js/list/node.js b/src/client/js/list/node.js new file mode 100644 index 0000000..820f927 --- /dev/null +++ b/src/client/js/list/node.js @@ -0,0 +1,18 @@ + + + +/** + * A node, often used in List implementations. + * + * @param {Object} data The data to be assigned to the node. + * @constructor + */ +var Node = function(data) { + this.data = data; + this.previous = null; + this.next = null; +}; + + +/** */ +module.exports = Node; diff --git a/src/client/js/mailman.js b/src/client/js/mailman.js index c70e56d..8dbbaf8 100644 --- a/src/client/js/mailman.js +++ b/src/client/js/mailman.js @@ -1,234 +1,298 @@ -var $ = require('jquery'); +/** + * Intercom: https://github.com/diy/intercom.js/ + * Tips on using intercom with GAS: https://github.com/googlesamples/apps-script-dialog2sidebar + * + * + */ + +var Util = require('./util.js'); +var Cards = require('./cards-handler.js'); +var NavBar = require('./nav/navigation-bar.js'); +var PubSub = require('pubsub-js'); +var Rules = require('./data/rule-container.js'); +var RuleTypes = require('./data/rule-types.js'); +var EmailRule = require('./data/email-rule.js'); +var Database = require('./data/database.js'); +var Keys = require('./data/prop-keys.js'); +var RulesListView = require('./rules/rules-list-view.js'); +//var Intercom = require('./intercom.js'); var MailMan = function() { - // *** GLOBAL VARIABLES *** // - var self = null; - var state = null; - var maxState = null; - var slideWidth = null; - var duration = 400; + // ***** CONSTANTS ***** // - // ********** PUBLIC **********// + //***** LOCAL VARIABLES *****// + var self; + + // The object used to communicate between the sidebar and the RTE (Rich Text Editor) + var intercom; + + // How long to wait for the dialog to check-in before assuming it's been closed, in milliseconds. + var DIALOG_TIMEOUT_MS = 2000; + + // This handles much of the configuration of the Cards. + var cards; + + // This handles all the nav-bar navigation. + var navBar; + + var database = new Database(); + + /** + * Holds a mapping from dialog ID to the ID of the timeout that is used to + * check if it was lost. This is needed so we can cancel the timeout when + * the dialog is closed. + */ + var timeoutIds = {}; + + var rules; + + var rulesListView; + var cardsView = $('#cards-view'); + + //***** PUBLIC *****// + + /** + * Performs basic set up of the Mailman environment. + * + * @constructor + */ this.init = function() { self = this; - state = 0; - maxState = 1; - slideWidth = $('.slide').width(); - $('#toButton').fadeTo(0, 0); - setFadeCharacteristics($('#toLine'), $('#toLine').children('.rest-right')); + // UI Configuration + // All UI Bindings + $('#step').on('click', self.next); + $('#done').on('click', self.done); + $('#back').on('click', self.back); + $('#help').on('click', self.onHelpClick); - $('#subjectButton').fadeTo(0, 0); - setFadeCharacteristics($('#subjectLine'), $('#subjectLine').children('.rest-right')); + intercom = Intercom.getInstance(); + contentArea = $('#content-area'); + rulesListView = new RulesListView($('#layout-container')); + cards = new Cards(contentArea, null); - $('#back').fadeTo(0, 0); - $('#back').prop('disabled', true); + rulesListView.setTriggerHandler(function(e) { + cards.setType(RuleTypes.TRIGGER); - $('#back').on('click', back); - $('#next').on('click', next); - $('#ccSpan').on('click', ccClick); - $('#bccSpan').on('click', bccClick); - $('.docs-icon').on('click', getSelection); + setButtonState(); - $('.waffle-range-selection-container').on('focusin', function() { - $(this).addClass('waffle-range-selection-container-focus'); - }).on('focusout', function() { - $(this).removeClass('waffle-range-selection-container-focus'); + navBar = new NavBar($('#nav-row'), 3, function(e) { + var node = e.data; + + cards.jumpTo(node.name); + }); + navBar.buildNavTree(cards.getActiveNode()); + + rulesListView.hide(); + Util.setHidden(cardsView, false); }); - // Close the DDL on click outside the DDL - // http://stackoverflow.com/questions/485453/best-way-to-get-the-original-target/11298886#11298886 - $(document).on('click', function(event) { - // The DDL is not yet visible - if (!$('#select-ddl').is(':visible')) { - return; - } - // The clicked element is a child of the DDL - if ($(event.target).parents('#select-ddl').length > 0) { - return; - } - // The clicked element is a child of the DDL button. - if ($(event.target).parents('#select-ddl-button').length > 0) { - return; - } + rulesListView.setInstantHandler(function(e) { + cards.setType(RuleTypes.INSTANT); - $('#select-ddl').css({ - 'display': 'none' + setButtonState(); + + navBar = new NavBar($('#nav-row'), 3, function(e) { + var node = e.data; + + cards.jumpTo(node.name); }); + navBar.buildNavTree(cards.getActiveNode()); + + rulesListView.hide(); + Util.setHidden(cardsView, false); + }); + + rulesListView.setDeleteHandler(function(rule) { + rules.remove(rule); }); - // Open the DDL on click - $('#select-ddl-button').on('click', function() { - $('#select-ddl').css({ - 'display': 'initial' + rulesListView.setEditHandler(function(rule) { + cards.setRule(rule); + + setButtonState(); + + navBar = new NavBar($('#nav-row'), 3, function(e) { + var node = e.data; + + cards.jumpTo(node.name); }); + navBar.buildNavTree(cards.getActiveNode()); + + rulesListView.hide(); + Util.setHidden(cardsView, false); }); - $('body').width(window.innerWidth) - .height(window.innerHeight); - window.onresize = function() { - $('body').width(window.innerWidth) - .height(window.innerHeight); - }; - }; + database.load(Keys.RULE_KEY, function(config) { + try { + rules = new Rules(config); + } + catch (e) { + // We don't need to fail if the rule isn't properly formatted. Just log and continue on. + console.log(e); + } - // ********** PRIVATE **********// - - var getSelection = function() { - // I always pass the input's parent in. I know this function is awful. - // TODO adjust HTML to allow consistant input/button relationship - if ($(this).siblings('input').length > 0) { - google.script.run - .withSuccessHandler(insertSelection) - .withUserObject($(this).parent()) - .getSheetSelection(); - } else if ($(this).parent().parent().parent().children('input').length > 0) { - google.script.run - .withSuccessHandler(insertSelection) - .withUserObject($(this).parent().parent().parent()) - .getSheetSelection(); - } else if ($(this).parent().parent().siblings('.waffle-row').length > 0) { - google.script.run - .withSuccessHandler(insertSelection) - .withUserObject($(this).parent().parent().siblings('.waffle-row')) - .getSheetSelection(); - } - }; + rulesListView.setRulesContainer(rules); + }, function() { + rules = new Rules({}); + rulesListView.setRulesContainer(rules); + }); + // PubSub bindings - var insertSelection = function(selection, uObj) { - var input = $(uObj).children('input'); - input.insertAtCaret(selection); + // It's important to note the flow of the program here. + // When cards.jumpTo is called, this pubsub function is called. + // jump to a card > rebuild the nav tree + PubSub.subscribe('Cards.jumpTo', function(msg, data) { + var activeNode = cards.getActiveNode(); + navBar.buildNavTree(cards.getActiveNode()); + setButtonState(); + }); }; - var submitData = function() { - console.log('IMPLEMENT ME'); - google.script.host.close(); - }; + /** + * This function goes to the next card. + * + * TODO There is non-DRY code between this function and this.back. + * TODO Move this into the CardHandler or a CardsView. + * @param {event} event The event that triggered the function call. + */ + this.next = function(event) { + var active = cards.next(); + setButtonState(); - var ccClick = function() { - $('#ccSpan').remove(); - $('#ccLine').append(''); - $('#ccLine').append( - getIconButton() - .prop('id', 'ccButton') - .fadeTo(100, 0) - .on('click', getSelection)); - setFadeCharacteristics($('#ccLine'), $('#ccLine')); + navBar.buildNavTree(active); }; - var bccClick = function() { - $('#bccSpan').remove(); - $('#bccLine').append(''); - $('#bccLine').append( - getIconButton() - .prop('id', 'bccButton') - .fadeTo(100, 0) - .on('click', getSelection)); - setFadeCharacteristics($('#bccLine'), $('#bccLine')); + /** + * This function goes to the previous card. + * + * TODO There is non-DRY code between this function and this.next. + * TODO Move this into the CardHandler or a CardsView. + * @param {event} event The event that triggered the function call. + */ + this.back = function(event) { + var active = cards.back(); + setButtonState(); + + navBar.buildNavTree(active); }; - var back = function() { - if (state > 0) { - state--; + /** + * Submits data back to google. + * + * TODO Move this into the CardHandler or a CardsView. + * @param {event} event The event that triggered the function call. + */ + this.done = function(event) { + var rule = cards.getRule(); - $('#slider').animate({ - left: '+=' + slideWidth - }, this.duration); + if (rules.indexOf(rule.getID()) !== -1) { + rules.update(rule); + } + else { + rules.add(rule.toConfig()); + } - // Fade out the button if we are at the base state. - if (state === 0) { - $('#back').fadeTo(400, 0); - $('#back').prop('disabled', true); + database.save(Keys.RULE_KEY, rules.toConfig(), function() { + if (cards.getRuleType() === RuleTypes.INSTANT) { + google.script.run + .instantEmail(rule.toConfig()); } - $('#next').html('Next'); - $('#next').off().on('click', next); - } - }; + setTimeout(function() { + rulesListView.show(); + Util.setHidden(cardsView, true); - var next = function(event) { - if (state < maxState) { - state++; + navBar.cleanup(); + cards.cleanup(); - $('#back').fadeTo(400, 1); - $('#back').prop('disabled', false); + // TODO reload rules + }, 1000); + }); + }; - $('#slider').animate({ - left: '-=' + slideWidth - }, this.duration); + /** + * This function toggles the state of the help

    tags. + *TODO + */ + this.onHelpClick = function() { + cards.toggleHelp(); + }; - $('#next').html('Done'); - $('#next').off().on('click', done); + //***** PRIVATE *****// + + /** + * Using the Cards handler, this sets the state of the buttons. + * Depending on the active Card, different buttons may be shown. + * + */ + var setButtonState = function() { + // Default state + Util.setHidden($('#done'), true); + Util.setHidden($('#step'), false); + Util.setHidden($('#back'), false); + + if (cards.isFirst()) { + Util.setHidden($('#back'), true); + } + else if (cards.isLast()) { + Util.setHidden($('#done'), false); + Util.setHidden($('#step'), true); } }; - var done = function() { - // SUBMIT THE INFO BACK TO SHEETS - var container; - var to = $('#toLine').children('input').val(); - var cc = $('#ccLine').children('input').val() == null || $('#ccLine').children('input').val() === '' ? null : $('#ccLine').children('input').val(); - var bcc = $('#bccLine').children('input').val() == null || $('#bccLine').children('input').val() === '' ? null : $('#bccLine').children('input').val(); - var subject = $('#subject').val(); - var body = $('#body').val(); - var range = $('#range').val(); - var comparison = $('#comparison').html(); - var value = $('#value-to-watch').val(); - var lastSent = $('#last-sent').val(); - - // TODO Must have to, subject, body, range (could just default to whole sheet) comparison and value - - console.log('to: ' + to + - '\ncc: ' + cc + - '\nbcc: ' + bcc + - '\nsubject: ' + subject + - '\nbody: ' + body + - '\nrange: ' + range + - '\ncomparison: ' + comparison + - '\nvalue: ' + value + - '\nlast: ' + lastSent - - ); - - google.script.run - .withSuccessHandler(submitData) - .createRule(to, cc, bcc, subject, body, range, comparison, value, lastSent); - }; + var onRTEOpened = function(dialogId) { - var getIconButton = function() { - return $('#toButton').clone().prop('class', function() { - return $(this).prop('class') + ' rest-right'; - }); + intercom.on(dialogId, function(data) { + + switch (data.state) { + case 'done': + console.log('Dialog submitted.\n'); + + getNode('Body').data.setValue(data.message); + + forget(dialogId); + break; + case 'checkIn': + forget(dialogId); + watch(dialogId); + break; + case 'lost': + console.log('Dialog lost.\n'); + break; + default: + throw 'Unknown dialog state: ' + data.state; + } + }); }; /** - * Searches for a .jfk-button in search and applies fading effects to it - * based upon the mouse state over line - * - * @param {jQuery} line The object to watch for events (hoverin, hoverout, focusin, focusout). - * @param {jQuery} search The object to look for button in. + * Watch the given dialog, to detect when it's been X-ed out. * + * @param {string} dialogId The ID of the dialog to watch. */ - var setFadeCharacteristics = function(line, search) { - var button = search.children('.jfk-button'); - - line.hover(function(e) { - button.fadeTo(100, 1); - }, function(e) { - var input = line.children('.input-full'); - if (!input.is(':focus')) { - button.fadeTo(100, 0); - } - }) - .on('focusin', function() { - button.fadeTo(100, 1); - }) - .on('focusout', function() { - button.fadeTo(100, 0); - }); + var watch = function(dialogId) { + timeoutIds[dialogId] = window.setTimeout(function() { + intercom.emit(dialogId, 'lost'); + }, DIALOG_TIMEOUT_MS); }; + + /** + * Stop watching the given dialog. + * @param {string} dialogId The ID of the dialog to watch. + */ + var forget = function(dialogId) { + if (timeoutIds[dialogId]) { + window.clearTimeout(timeoutIds[dialogId]); + } + }; + + this.init(); }; + +/** */ module.exports = MailMan; diff --git a/src/client/js/nav/nav-bar.html b/src/client/js/nav/nav-bar.html new file mode 100644 index 0000000..a39c5db --- /dev/null +++ b/src/client/js/nav/nav-bar.html @@ -0,0 +1 @@ +

    diff --git a/src/client/js/nav/navigation-bar.js b/src/client/js/nav/navigation-bar.js new file mode 100644 index 0000000..a3ccc16 --- /dev/null +++ b/src/client/js/nav/navigation-bar.js @@ -0,0 +1,50 @@ + +var html = require('./nav-bar.html'); + +var NavigationBar = function(appendTo, maxNavItems, onClick) { + // Private variables + var self = this; + var base = $(html); + var clickHandler = onClick; + var maxItems = maxNavItems; + + // Public Variables + + /** + * + * + * @constructor + */ + this.init = function(appendTo) { + appendTo.prepend(base); + }; + + //***** Private Methods *****// + + //***** Privileged Methods *****// + this.buildNavTree = function(node) { + base.empty(); + + var current = node; + for (var i = 0; i < maxItems; i++) { + + if (current !== null) { + var newLink = $('' + current.name + '') + .on('click', current, clickHandler); + + base.prepend(newLink) + .prepend(' > '); + + current = current.previous; + } + } + }; + + this.cleanup = function() { + base.remove(); + }; + + this.init(appendTo); +}; + +module.exports = NavigationBar; diff --git a/src/client/js/rich-text-editor.js b/src/client/js/rich-text-editor.js new file mode 100644 index 0000000..8ef3612 --- /dev/null +++ b/src/client/js/rich-text-editor.js @@ -0,0 +1,73 @@ +require('jquery.hotkeys'); +require('./wysiwyg-editor.js'); + +//var Intercom = require('./intercom.js'); + +$(document).ready(function() { + var wysiwyg = $('#editor').wysiwyg(); + var selection; + + /** + * The ID of this dialog. This is set once when the template is rendered. + */ + var DIALOG_ID = 'dialog'; + + /** + * How often to check-in with the server, in milliseconds. + */ + var CHECKIN_INTERVAL_MS = 500; + + /** + * Instance of the Intercom.js library. + */ + var intercom = Intercom.getInstance(); + console.log(intercom); + console.log('Dialog: ' + DIALOG_ID); + + var button = $('#done').on('click', onSubmit); + var linkButton = $('#link-btn').on('click', onLinkClick); + var linkInsertButton = $('#link-insert-btn').on('click', onLinkInsertClick); + var linkInput = $('#link-url-value').on('click', wysiwyg.saveSelection); + + + // Sets up an interval to check-in with the server every few seconds, so we can tell + // if it's been X-ed out. + window.setInterval(function() { + intercom.emit(DIALOG_ID, {state: 'checkIn'}); + }, CHECKIN_INTERVAL_MS); + + function onLinkInsertClick(e) { + var urlInput = $('#link-url-value'); + + wysiwyg.restoreSpecificSelection(selection); + + console.log(urlInput.val()); + wysiwyg.execCommand('createLink', urlInput.val()); + + var menu = $('#rte-link-menu'); + menu.addClass('hidden'); + + } + + function onLinkClick(e) { + selection = wysiwyg.getCurrentRange(); + + var menu = $('#rte-link-menu'); + menu.toggleClass('hidden'); + } + + /** + * Runs when the form is submitted. + */ + function onSubmit() { + // Here is where you would add custom logic specific to your form. + // You may need to make additional google.script.run calls to store various information + // collected in the dialog. + + + console.log('RTE: Submitting... ' + $('#editor').cleanHtml()); + intercom.emit(DIALOG_ID, {state: 'done', message: $('#editor').cleanHtml()}); + google.script.host.close(); + } + +}); diff --git a/src/client/js/rules/rule-list-item.html b/src/client/js/rules/rule-list-item.html new file mode 100644 index 0000000..cabf9ac --- /dev/null +++ b/src/client/js/rules/rule-list-item.html @@ -0,0 +1,28 @@ +
  • + + + mail + keyboard_return + + + mail_outline + + + + + + To +
    + To +
    + + Subject +
    + Subject +
    + +
    + + delete + +
  • diff --git a/src/client/js/rules/rule-list-item.js b/src/client/js/rules/rule-list-item.js new file mode 100644 index 0000000..cc00230 --- /dev/null +++ b/src/client/js/rules/rule-list-item.js @@ -0,0 +1,104 @@ + +var baseHTML = require('./rule-list-item.html'); +var EmailRule = require('../data/email-rule.js'); +var ID = require('../data/id.js'); +var Util = require('../util.js'); +var RuleTypes = require('../data/rule-types.js'); + + + +/** + * Used to display an EmailRule. It has icons for editing and deleting the rule. + * + * @constructor + * @param {jquery} appendTo The object to append this component to. + * @param {EmailRule} rule The rule to display. + */ +var RuleListItem = function(appendTo, rule) { + // private variables + var self = this; + var base = $(baseHTML); + + var to = base.find('[data-id="to"]'); + var toTooltip = base.find('[data-id="to-tooltip"]'); + var subject = base.find('[data-id="subject"]'); + var subjectTooltip = base.find('[data-id="subject-tooltip"]'); + var triggerIcon = base.find('[data-id="trigger-icon"]'); + var instantIcon = base.find('[data-id="instant-icon"]'); + var deleteIcon = base.find('[data-id="delete"]'); + + var rule; + + // public variables + + //***** private methods *****// + + this.init_ = function(appendTo, rule) { + rule = rule; + + to.text(rule.to); + subject.text(rule.subject); + + if (rule.ruleType === RuleTypes.TRIGGER) { + Util.setHidden(instantIcon, true); + Util.setHidden(triggerIcon, false); + } + else if (rule.ruleType === RuleTypes.INSTANT) { + Util.setHidden(instantIcon, false); + Util.setHidden(triggerIcon, true); + } + else { + throw new Error('Unknown ruletype.'); + } + + appendTo.append(base); + + upgradeTooltip(toTooltip, to); + upgradeTooltip(subjectTooltip, subject); + }; + + var upgradeTooltip = function(tooltip, item) { + var id = ID(); + item.attr('id', id); + tooltip.attr('data-mdl-for', id); + + tooltip.addClass('mdl-tooltip'); + + componentHandler.upgradeElement(tooltip[0], 'MaterialTooltip'); + }; + + //***** privileged methods *****// + + /** + * Sets the handler for when the delete icon is clicked. + * + * @param {Function} callback The function to call. + */ + this.setDeleteHandler = function(callback) { + deleteIcon.on('click', rule, callback); + }; + + /** + * Sets the handler for when the edit icon is clicked. + * + * @param {Function} callback The function to call. + */ + this.setEditHandler = function(callback) { + triggerIcon.on('click', rule, callback); + instantIcon.on('click', rule, callback); + }; + + /** + * Cleans up this component. This involves removing the HTML from the DOM. + * + */ + this.cleanup = function() { + base.remove(); + }; + + this.init_(appendTo, rule); +}; + + +/** */ +module.exports = RuleListItem; diff --git a/src/client/js/rules/rules-list-view.html b/src/client/js/rules/rules-list-view.html new file mode 100644 index 0000000..56476e1 --- /dev/null +++ b/src/client/js/rules/rules-list-view.html @@ -0,0 +1,21 @@ +
    +
    +
      + +
    +
    + +
    + + + +
    + +
    diff --git a/src/client/js/rules/rules-list-view.js b/src/client/js/rules/rules-list-view.js new file mode 100644 index 0000000..8df8919 --- /dev/null +++ b/src/client/js/rules/rules-list-view.js @@ -0,0 +1,160 @@ + +var baseHTML = require('./rules-list-view.html'); + +var EmailRule = require('../data/email-rule.js'); +var RuleListItem = require('./rule-list-item.js'); +var Util = require('../util.js'); +var PubSub = require('pubsub-js'); + + + +/** + * This view displays all of the EmailRules. Each EmailRule corresponds to a RuleListItem. + * This view responds to the following PubSub events: Rules.delete, Rules.add, Rules.update. + * + * @constructor + * @param {jquery} appendTo The element this view should be appended to. + */ +var RulesListView = function(appendTo) { + // private variables + var self = this; + var base = $(baseHTML); + var list = base.find('ul'); + var triggerButton = base.find('[data-id="trigger-button"]'); + var instantButton = base.find('[data-id="instant-button"]'); + var ruleItems = []; + var ruleContainer; + + // Event callbacks + var deletionCallback; + var editCallback; + var triggerCB; + var instantCB; + + // public variables + + + //***** private methods *****// + + this.init_ = function(appendTo) { + appendTo.append(base); + + triggerButton.on('click', newTrigger); + instantButton.on('click', newInstant); + + PubSub.subscribe('Rules.delete', rebuild); + PubSub.subscribe('Rules.add', rebuild); + PubSub.subscribe('Rules.update', rebuild); + }; + + var itemDelete = function(e) { + deletionCallback(e.data); + }; + + var itemEdit = function(e) { + editCallback(e.data); + }; + + var newTrigger = function(e) { + triggerCB(e); + }; + + var newInstant = function(e) { + instantCB(e); + }; + + var rebuild = function() { + for (var i = 0; i < ruleItems.length; i++) { + ruleItems[i].cleanup(); + } + + ruleItems = []; + for (var i = 0; i < ruleContainer.length(); i++) { + self.addRule(ruleContainer.get(i)); + } + }; + + //***** privileged methods *****// + + /** + * Sets the RulesContainer this view uses. + * + * @param {RuleContainer} container This is the model used by the view to update. + */ + this.setRulesContainer = function(container) { + ruleContainer = container; + rebuild(); + }; + + /** + * Adds a new RuleListItem to this view. + * + * @param {EmailRule} rule The model that is used to build the view. + */ + this.addRule = function(rule) { + + var item = new RuleListItem(list, rule); + item.setDeleteHandler(itemDelete); + item.setEditHandler(itemEdit); + + ruleItems.push(item); + }; + + /** + * Hides the RulesListView. + * + */ + this.hide = function() { + Util.setHidden(base, true); + }; + + /** + * Shows the RulesListView. + * + */ + this.show = function() { + Util.setHidden(base, false); + }; + + /** + * Sets the handler for each RuleListItem deletion. + * + * @param {Function} callback Called when the delete icon is clicked. + */ + this.setDeleteHandler = function(callback) { + deletionCallback = callback; + }; + + /** + * Sets the handler for each RuleListItem edit. + * + * @param {Function} callback Called when the edit icon is clicked. + */ + this.setEditHandler = function(callback) { + editCallback = callback; + }; + + /** + * Sets the handler for the new trigger button click. + * + * @param {Function} callback Called when the add trigger button is clicked. + */ + this.setTriggerHandler = function(callback) { + triggerCB = callback; + }; + + /** + * Sets the handler for the instant email button click. + * + * @param {Function} callback Called when the instant trigger button is clicked. + */ + this.setInstantHandler = function(callback) { + instantCB = callback; + }; + + this.init_(appendTo); +}; + + +/** */ +module.exports = RulesListView; diff --git a/src/client/js/util.js b/src/client/js/util.js index dce5e68..c72031c 100644 --- a/src/client/js/util.js +++ b/src/client/js/util.js @@ -1,4 +1,9 @@ -var $ = require('jquery'); + + +/** + * + */ +var Util = module.exports = function() {}; $.fn.extend({ insertAtCaret: function(myValue) { @@ -17,3 +22,66 @@ $.fn.extend({ obj.scrollTop = scrollTop; } }); + + +/** + * Resolves an issue with jQuery UI's autocomplete not resizing the DDL properly. + * http://stackoverflow.com/questions/5643767/jquery-ui-autocomplete-width-not-set-correctly + */ +jQuery.ui.autocomplete.prototype._resizeMenu = function() { + var ul = this.menu.element; + ul.outerWidth(this.element.outerWidth()); +}; + + +/** + * Reverses the properties in a JavaScript object. + * Found here: + *http://stackoverflow.com/questions/10974493/javascript-quickly-lookup-value-in-object-like-we-can-with-properties + * + * @param {object} map The object that we are interested in flipping. + * @return {object} An object with all keys and values swapped. + */ +Util.reverseObject = function(map) { + var reverseMap = {}; + + for (j in map) { + if (Object.prototype.hasOwnProperty.call(map, j)) { + reverseMap[map[j]] = j; + } + } + + return reverseMap; +}; + + +/** + * A generic UI element hider. Removes the object from the flow of the document. + * + * @param {jquery} object The object to apply the change to. + * @param {boolean} state True for display:none, false for default. + */ +Util.setHidden = function(object, state) { + if (state) { + object.addClass('hidden'); + } + else { + object.removeClass('hidden'); + } +}; + + +/** + * A generic UI element hider. Doesn't remove the object from the flow of the document. + * + * @param {jquery} object The object to apply the change to. + * @param {boolean} state True for visible, false for invisible. + */ +Util.setVisibility = function(object, state) { + if (state) { + object.removeClass('invisible'); + } + else { + object.addClass('invisible'); + } +}; diff --git a/src/client/js/wysiwyg-editor.js b/src/client/js/wysiwyg-editor.js new file mode 100644 index 0000000..20d1a3b --- /dev/null +++ b/src/client/js/wysiwyg-editor.js @@ -0,0 +1,355 @@ +// The MIT License +// +// Copyright (c) 2013 Damjan Vujnovic, David de Florinier, Gojko Adzic +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is furnished to do +// so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/* @fileoverview + * Provides full Bootstrap based, multi-instance WYSIWYG editor. + * + * "Name" = 'bootstrap-wysiwyg' + * "Author" = 'Various, see LICENSE' + * "Version" = '1.0.4' + * "About" = 'A tiny Bootstrap and jQuery based WYSIWYG rich text editor based on the browser function execCommand.' + */ + +var WYSIWYG = module.exports = function() {}; + +'use strict'; + +var readFileIntoDataUrl = function(fileInfo) { + var loader = $.Deferred(), + fReader = new FileReader(); + fReader.onload = function(e) { + loader.resolve(e.target.result); + }; + fReader.onerror = loader.reject; + fReader.onprogress = loader.notify; + fReader.readAsDataURL(fileInfo); + return loader.promise(); +}; +$.fn.cleanHtml = function(o) { + if ($(this).data("wysiwyg-html-mode") === true) { + $(this).html($(this).text()); + $(this).attr('contenteditable', true); + $(this).data('wysiwyg-html-mode', false); + } + + // Strip the images with src="data:image/.." out; + if (o === true && $(this).parent().is("form")) { + var gGal = $(this).html; + if ($(gGal).has("img").length) { + var gImages = $("img", $(gGal)); + var gResults = []; + var gEditor = $(this).parent(); + $.each(gImages, function(i, v) { + if ($(v).attr('src').match(/^data:image\/.*$/)) { + gResults.push(gImages[i]); + $(gEditor).prepend(""); + $(v).attr('src', 'postedimage/' + i); + } + }); + } + } + var html = $(this).html(); + return html && html.replace(/(
    |\s|

    <\/div>| )*$/, ''); +}; +$.fn.wysiwyg = function(userOptions) { + var editor = this, + selectedRange, + options, + toolbarBtnSelector, + updateToolbar = function() { + if (options.activeToolbarClass) { + $(options.toolbarSelector).find(toolbarBtnSelector).each(function() { + var commandArr = $(this).data(options.commandRole).split(' '), + command = commandArr[0]; + + // If the command has an argument and its value matches this button. == used for string/number comparison + if (commandArr.length > 1 && document.queryCommandEnabled(command) && document.queryCommandValue(command) === commandArr[1]) { + $(this).addClass(options.activeToolbarClass); + // Else if the command has no arguments and it is active + } else if (commandArr.length === 1 && document.queryCommandEnabled(command) && document.queryCommandState(command)) { + $(this).addClass(options.activeToolbarClass); + // Else the command is not active + } else { + $(this).removeClass(options.activeToolbarClass); + } + }); + } + }, + execCommand = function(commandWithArgs, valueArg) { + var commandArr = commandWithArgs.split(' '), + command = commandArr.shift(), + args = commandArr.join(' ') + (valueArg || ''); + + var parts = commandWithArgs.split('-'); + + if (parts.length === 1) { + document.execCommand(command, false, args); + } else if (parts[0] === 'format' && parts.length === 2) { + document.execCommand('formatBlock', false, parts[1]); + } + + editor.trigger('change'); + updateToolbar(); + }, + bindHotkeys = function(hotKeys) { + $.each(hotKeys, function(hotkey, command) { + editor.keydown(hotkey, function(e) { + if (editor.attr('contenteditable') && editor.is(':visible')) { + e.preventDefault(); + e.stopPropagation(); + execCommand(command); + } + }).keyup(hotkey, function(e) { + if (editor.attr('contenteditable') && editor.is(':visible')) { + e.preventDefault(); + e.stopPropagation(); + } + }); + }); + + editor.keyup(function() { + editor.trigger('change'); + }); + }, + getCurrentRange = function() { + var sel, range; + if (window.getSelection) { + sel = window.getSelection(); + if (sel.getRangeAt && sel.rangeCount) { + range = sel.getRangeAt(0); + } + } else if (document.selection) { + range = document.selection.createRange(); + } + return range; + }, + saveSelection = function() { + selectedRange = getCurrentRange(); + }, + restoreSelection = function() { + var selection; + if (window.getSelection || document.createRange) { + selection = window.getSelection(); + if (selectedRange) { + try { + selection.removeAllRanges(); + } catch (ex) { + document.body.createTextRange().select(); + document.selection.empty(); + } + selection.addRange(selectedRange); + } + } else if (document.selection && selectedRange) { + selectedRange.select(); + } + }, + restoreSpecificSelection = function(setTo) { + var selection; + if (window.getSelection || document.createRange) { + selection = window.getSelection(); + if (setTo) { + try { + selection.removeAllRanges(); + } catch (ex) { + document.body.createTextRange().select(); + document.selection.empty(); + } + selection.addRange(setTo); + } + } else if (document.selection && setTo) { + setTo.select(); + } + }, + // Adding Toggle HTML based on the work by @jd0000, but cleaned up a little to work in this context. + toggleHtmlEdit = function() { + if ($(editor).data("wysiwyg-html-mode") !== true) { + var oContent = $(editor).html(); + var editorPre = $("
    ");
    +                $(editorPre).append(document.createTextNode(oContent));
    +                $(editorPre).attr('contenteditable', true);
    +                $(editor).html(' ');
    +                $(editor).append($(editorPre));
    +                $(editor).attr('contenteditable', false);
    +                $(editor).data("wysiwyg-html-mode", true);
    +                $(editorPre).focus();
    +            } else {
    +                $(editor).html($(editor).text());
    +                $(editor).attr('contenteditable', true);
    +                $(editor).data('wysiwyg-html-mode', false);
    +                $(editor).focus();
    +            }
    +        },
    +
    +        insertFiles = function(files) {
    +            editor.focus();
    +            $.each(files, function(idx, fileInfo) {
    +                if (/^image\//.test(fileInfo.type)) {
    +                    $.when(readFileIntoDataUrl(fileInfo)).done(function(dataUrl) {
    +                        execCommand('insertimage', dataUrl);
    +                        editor.trigger('image-inserted');
    +                    }).fail(function(e) {
    +                        options.fileUploadError("file-reader", e);
    +                    });
    +                } else {
    +                    options.fileUploadError("unsupported-file-type", fileInfo.type);
    +                }
    +            });
    +        },
    +        markSelection = function(input, color) {
    +            restoreSelection();
    +            if (document.queryCommandSupported('hiliteColor')) {
    +                document.execCommand('hiliteColor', false, color || 'transparent');
    +            }
    +            saveSelection();
    +            input.data(options.selectionMarker, color);
    +        },
    +        bindToolbar = function(toolbar, options) {
    +            toolbar.find(toolbarBtnSelector).click(function() {
    +                restoreSelection();
    +                editor.focus();
    +
    +                if ($(this).data(options.commandRole) === 'html') {
    +                    toggleHtmlEdit();
    +                } else {
    +                    execCommand($(this).data(options.commandRole));
    +                }
    +                saveSelection();
    +            });
    +            toolbar.find('[data-toggle=dropdown]').click(restoreSelection);
    +
    +            toolbar.find('input[type=text][data-' + options.commandRole + ']').on('webkitspeechchange change', function() {
    +                var newValue = this.value; /* ugly but prevents fake double-calls due to selection restoration */
    +                this.value = '';
    +                restoreSelection();
    +                if (newValue) {
    +                    editor.focus();
    +                    execCommand($(this).data(options.commandRole), newValue);
    +                }
    +                saveSelection();
    +            }).on('focus', function() {
    +                var input = $(this);
    +                if (!input.data(options.selectionMarker)) {
    +                    markSelection(input, options.selectionColor);
    +                    input.focus();
    +                }
    +            }).on('blur', function() {
    +                var input = $(this);
    +                if (input.data(options.selectionMarker)) {
    +                    markSelection(input, false);
    +                }
    +            });
    +            toolbar.find('input[type=file][data-' + options.commandRole + ']').change(function() {
    +                restoreSelection();
    +                if (this.type === 'file' && this.files && this.files.length > 0) {
    +                    insertFiles(this.files);
    +                }
    +                saveSelection();
    +                this.value = '';
    +            });
    +        },
    +        initFileDrops = function() {
    +            editor.on('dragenter dragover', false)
    +                .on('drop', function(e) {
    +                    var dataTransfer = e.originalEvent.dataTransfer;
    +                    e.stopPropagation();
    +                    e.preventDefault();
    +                    if (dataTransfer && dataTransfer.files && dataTransfer.files.length > 0) {
    +                        insertFiles(dataTransfer.files);
    +                    }
    +                });
    +        };
    +    options = $.extend(true, {}, $.fn.wysiwyg.defaults, userOptions);
    +    toolbarBtnSelector = 'a[data-' + options.commandRole + '],button[data-' + options.commandRole + '],input[type=button][data-' + options.commandRole + ']';
    +    bindHotkeys(options.hotKeys);
    +
    +    // These are custome edits. We use these to access the private methods.
    +    this.execCommand = execCommand;
    +    this.saveSelection = saveSelection;
    +    this.restoreSelection = restoreSelection;
    +    this.restoreSpecificSelection = restoreSpecificSelection;
    +    this.getCurrentRange = getCurrentRange;
    +
    +    // Support placeholder attribute on the DIV
    +    var placeholder = $(this).attr('placeholder');
    +
    +    if (placeholder !== '') {
    +        // Set iniitial state without trashing existing text.
    +        if (placeholder !== '' && $(this).text() === '') {
    +            $(this).addClass('placeholderText');
    +            $(this).html(placeholder);
    +        }
    +        $(this).bind('focus', function() {
    +            if (placeholder !== '' && $(this).text() === placeholder) {
    +                $(this).removeClass('placeholderText');
    +                $(this).html('');
    +            }
    +        });
    +        $(this).bind('blur', function() {
    +            if (placeholder !== '' && $(this).text() === '') {
    +                $(this).addClass('placeholderText');
    +                $(this).html(placeholder);
    +            }
    +        });
    +    }
    +
    +    if (options.dragAndDropImages) {
    +        initFileDrops();
    +    }
    +    bindToolbar($(options.toolbarSelector), options);
    +    editor.attr('contenteditable', true)
    +        .on('mouseup keyup mouseout', function() {
    +            saveSelection();
    +            updateToolbar();
    +        });
    +    $(window).bind('touchend', function(e) {
    +        var isInside = (editor.is(e.target) || editor.has(e.target).length > 0),
    +            currentRange = getCurrentRange(),
    +            clear = currentRange && (currentRange.startContainer === currentRange.endContainer && currentRange.startOffset === currentRange.endOffset);
    +        if (!clear || isInside) {
    +            saveSelection();
    +            updateToolbar();
    +        }
    +    });
    +    return this;
    +};
    +$.fn.wysiwyg.defaults = {
    +		hotKeys: {
    +			'ctrl+b meta+b': 'bold',
    +			'ctrl+i meta+i': 'italic',
    +			'ctrl+u meta+u': 'underline',
    +			'ctrl+z meta+z': 'undo',
    +			'ctrl+y meta+y meta+shift+z': 'redo',
    +			'ctrl+l meta+l': 'justifyleft',
    +			'ctrl+r meta+r': 'justifyright',
    +			'ctrl+e meta+e': 'justifycenter',
    +			'ctrl+j meta+j': 'justifyfull',
    +			'shift+tab': 'outdent',
    +			'tab': 'indent'
    +		},
    +		toolbarSelector: '[data-role=editor-toolbar]',
    +		commandRole: 'edit',
    +		activeToolbarClass: 'btn-info',
    +		selectionMarker: 'edit-focus-marker',
    +		selectionColor: 'darkgrey',
    +		dragAndDropImages: true,
    +		fileUploadError: function (reason, detail) { console.log("File upload error", reason, detail); }
    +	};
    diff --git a/src/client/sass/_animations.scss b/src/client/sass/_animations.scss
    new file mode 100644
    index 0000000..8ed8280
    --- /dev/null
    +++ b/src/client/sass/_animations.scss
    @@ -0,0 +1,25 @@
    +@mixin rotate($deg: 90){
    +    $sDeg: #{$deg}deg;
    +
    +    -webkit-transform: rotate($sDeg);
    +    -moz-transform: rotate($sDeg);
    +    -ms-transform: rotate($sDeg);
    +    -o-transform: rotate($sDeg);
    +    transform: rotate($sDeg);
    +}
    +
    +@mixin animation($str) {
    +  -webkit-animation: #{$str};
    +  -moz-animation: #{$str};
    +  -ms-animation: #{$str};
    +  -o-animation: #{$str};
    +  animation: #{$str};
    +}
    +
    +@mixin transition($args...) {
    +  -webkit-transition: $args;
    +  -moz-transition: $args;
    +  -ms-transition: $args;
    +  -o-transition: $args;
    +  transition: $args;
    +}
    diff --git a/src/client/sass/_colors.scss b/src/client/sass/_colors.scss
    new file mode 100644
    index 0000000..3abd99f
    --- /dev/null
    +++ b/src/client/sass/_colors.scss
    @@ -0,0 +1,35 @@
    +// All these colors are from the CoE color palette here:
    +// https://www.edmonton.ca/city_government/documents/PDF/COE%20Complement%20Colour%20PaletteX.pdf
    +
    +// Primary Colors
    +$pantone-2945: #005087;
    +$pantone-2955: #193A5A;
    +$process-blue: #0081BC;
    +
    +// Office Complements
    +$empire-blue: #2F63AE;
    +$violet-night: #99479A;
    +
    +$spring-mist: #5DC3B5;
    +$sea-green: #25B18A;
    +
    +$yellow-lime: #B2C135;
    +$sunrise: #F8AA1A;
    +
    +$watermelon: #F05853;
    +$grey-flannel: #839899;
    +
    +$success-green: #027648;
    +$failure-red: #ED2D2C;
    +$warning-yellow: #F9D41B;
    +
    +// Other
    +$white: #FFFFFF;
    +
    +// MDL colors
    +$fade-gray: #9e9e9e;
    +$weird-green: #009688;
    +$demo-purple: #3F51B5;
    +
    +// Google Add Ons Colors
    +$bar-black: #323232;
    diff --git a/src/client/sass/material-icons.scss b/src/client/sass/material-icons.scss
    new file mode 100644
    index 0000000..8dea6f0
    --- /dev/null
    +++ b/src/client/sass/material-icons.scss
    @@ -0,0 +1,62 @@
    +/*Google Material Icons*/
    +@font-face {
    +    font-family: 'Material Icons';
    +    font-style: normal;
    +    font-weight: 400;
    +    src: url("https://fonts.gstatic.com/s/materialicons/v18/2fcrYFNaTjcS6g4U3t-Y5ZjZjT5FdEJ140U2DJYC3mY.woff2") format('woff2');
    +}
    +
    +.material-icons {
    +    font-family: 'Material Icons';
    +    font-weight: normal;
    +    font-style: normal;
    +    font-size: 24px;
    +    /* Preferred icon size */
    +    display: inline-block;
    +    line-height: 1;
    +    text-transform: none;
    +    letter-spacing: normal;
    +    word-wrap: normal;
    +    white-space: nowrap;
    +    direction: ltr;
    +    /* Support for all WebKit browsers. */
    +    -webkit-font-smoothing: antialiased;
    +    /* Support for Safari and Chrome. */
    +    text-rendering: optimizeLegibility;
    +    /* Support for Firefox. */
    +    -moz-osx-font-smoothing: grayscale;
    +    /* Support for IE. */
    +    font-feature-settings: 'liga';
    +}
    +/* Rules for sizing the icon. */
    +.material-icons.md-18 {
    +    font-size: 18px;
    +}
    +
    +.material-icons.md-24 {
    +    font-size: 24px;
    +}
    +
    +.material-icons.md-36 {
    +    font-size: 36px;
    +}
    +
    +.material-icons.md-48 {
    +    font-size: 48px;
    +}
    +/* Rules for using icons as black on a light background. */
    +.material-icons.md-dark {
    +    color: rgba(0, 0, 0, 0.54);
    +}
    +
    +.material-icons.md-dark.md-inactive {
    +    color: rgba(0, 0, 0, 0.26);
    +}
    +/* Rules for using icons as white on a dark background. */
    +.material-icons.md-light {
    +    color: rgba(255, 255, 255, 1);
    +}
    +
    +.material-icons.md-light.md-inactive {
    +    color: rgba(255, 255, 255, 0.3);
    +}
    diff --git a/src/client/sass/mdl-article.scss b/src/client/sass/mdl-article.scss
    new file mode 100644
    index 0000000..bde83c6
    --- /dev/null
    +++ b/src/client/sass/mdl-article.scss
    @@ -0,0 +1,130 @@
    +/**
    + * Copyright 2015 Google Inc. All Rights Reserved.
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *      http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +@import "_colors";
    +
    +.hidden {
    +  display: none;
    +}
    +
    +.mailman-primary {
    +  background: $pantone-2945;
    +}
    +
    +.demo-ribbon {
    +	width: 100%;
    +	height: 40vh;
    +	background-color: $pantone-2945;
    +	-webkit-flex-shrink: 0;
    +	-ms-flex-negative: 0;
    +	flex-shrink: 0;
    +}
    +
    +.demo-main {
    +	margin-top: -37vh;
    +	-webkit-flex-shrink: 0;
    +	-ms-flex-negative: 0;
    +	flex-shrink: 0;
    +}
    +
    +.demo-header {
    +	.mdl-layout__header-row {
    +		padding-left: 12px;
    +	}
    +
    +  .demo-crumbs {
    +    a {
    +      color: inherit;
    +      cursor: pointer;
    +
    +      &:hover {
    +        text-decoration: underline;
    +      }
    +    }
    +  }
    +}
    +
    +.demo-container {
    +	max-width: 1600px;
    +	width: calc(100% - 16px);
    +	margin: 0 auto;
    +}
    +
    +.demo-content {
    +	border-radius: 2px;
    +	padding: 52px 26px;
    +  position: relative;
    +
    +	h3 {
    +		margin-top: 48px;
    +	}
    +
    +  .help {
    +    margin-left: 24px;
    +    color: $fade-gray;
    +  }
    +
    +  .settings-button {
    +    position: absolute;
    +    right: 32px;
    +    bottom: 16px;
    +  }
    +}
    +
    +.demo-layout.is-small-screen {
    +	.demo-content {
    +		padding: 40px 28px;
    +	}
    +}
    +
    +.demo-footer {
    +	padding-left: 40px;
    +  position: fixed;
    +  bottom: 0px;
    +  // TODO Actually calculate this value using variables.
    +  width: calc(100% - 72px);
    +  background-color: transparent;
    +
    +	.mdl-mini-footer--link-list {
    +		a {
    +			font-size: 13px;
    +		}
    +	}
    +}
    +
    +.mm-body-field {
    +  resize: none;
    +}
    +
    +// MDL Changes
    +.mailman-button {
    +    background: $pantone-2945;
    +    color: $white;
    +
    +    &:hover {
    +      background: $pantone-2945;
    +      color: $white;
    +    }
    +}
    +
    +.mailman-button-success {
    +  background: $success-green;
    +  color: $white;
    +
    +  &:hover {
    +    background: $success-green;
    +    color: $white;
    +  }
    +}
    diff --git a/src/client/sass/rich-text-editor.scss b/src/client/sass/rich-text-editor.scss
    new file mode 100644
    index 0000000..2bc0271
    --- /dev/null
    +++ b/src/client/sass/rich-text-editor.scss
    @@ -0,0 +1,202 @@
    +@import "_colors";
    +
    +$icon-font-size: 24;
    +$button-color: #FFFFFF;
    +$text-color: #000000;
    +
    +@mixin no-select($type) {
    +    -webkit-touch-callout: $type;
    +    /* iOS Safari */
    +    -webkit-user-select: $type;
    +    /* Chrome/Safari/Opera */
    +    -khtml-user-select: $type;
    +    /* Konqueror */
    +    -moz-user-select: $type;
    +    /* Firefox */
    +    -ms-user-select: $type;
    +    /* Internet Explorer/Edge */
    +    user-select: $type;
    +    /* Non-prefixed version, currently not supported by any browser */
    +}
    +
    +li {
    +    a {
    +        color: #000000;
    +        text-decoration: none!important; //TODO remove !important
    +        @include no-select(none);
    +    }
    +}
    +
    +a {
    +  color: #0088cc;
    +  text-decoration: underline;
    +  cursor: auto;
    +}
    +
    +.rte-controls {
    +  // TODO Clean these up
    +  -webkit-align-items: center;
    +  -ms-flex-align: center;
    +  align-items: center;
    +
    +  display: -webkit-flex;
    +  display: -ms-flexbox;
    +  display: flex;
    +
    +  background-color: $pantone-2945;
    +}
    +
    +.rte-card {
    +  flex-grow: 1;
    +  width: inherit;
    +}
    +
    +.rte-layout {
    +  padding: 4px;
    +  overflow: hidden;
    +}
    +
    +.rte-footer {
    +  background-color: transparent;
    +}
    +
    +.dropdown-menu a {
    +    cursor: pointer;
    +}
    +
    +.btn-group {
    +  position: relative;
    +  display: inline-block;
    +  font-size: 0;
    +  vertical-align: middle;
    +  white-space: nowrap;
    +  margin: 0px 8px;
    +}
    +
    +[contentEditable=true]:empty:not(:focus):before {
    +    content: attr(data-placeholder);
    +}
    +
    +.imgUpload {
    +    width: 0;
    +    height: 0;
    +    position: absolute;
    +}
    +/*
    +	Font Sizes
    +	fs represents the font-size attribute; therefore,
    +	fs-five would be the equivalent to font-size: 5
    +  This is used in font size selection dropdown.
    +*/
    +.fs-One {
    +    font-size: x-small;
    +}
    +
    +.fs-Three {
    +    font-size: medium;
    +}
    +
    +.fs-Five {
    +    font-size: x-large;
    +}
    +/** MDL Overwrites **/
    +a.mdl-button {
    +    min-width: inherit;
    +    padding: 0;
    +    color: $button-color;
    +}
    +
    +.mdl-textfield__label:after {
    +  background-color: $pantone-2945;
    +}
    +
    +.mdl-layout__header {
    +    background-color: transparent;
    +    display: inherit;
    +    box-shadow: none;
    +
    +    .mdl-layout__header-row {
    +        height: inherit;
    +    }
    +}
    +
    +.full-text-editor {
    +    width: 100%;
    +    padding: 0px 2px;
    +
    +    .mdl-textfield__input {
    +        min-height: 300px;
    +        max-height: 400px;
    +        overflow: auto;
    +    }
    +    .mdl-textfield__label {
    +      bottom: -20px;
    +    }
    +}
    +
    +.mdl-textfield__input {
    +    color: $text-color;
    +}
    +
    +.rte-button-success {
    +  width: 72px;
    +
    +  background: $success-green;
    +  color: $white;
    +
    +  &:hover {
    +    background: $success-green;
    +    color: $white;
    +  }
    +}
    +
    +// This is for the link menu
    +.rte-link-container {
    +  display: inline;
    +  position: relative;
    +}
    +.rte-menu {
    +  position: absolute;
    +  right: 0px;
    +
    +  z-index: 999;
    +
    +  display: block;
    +  background: $white;
    +  margin: 0;
    +  padding: 10px;;
    +  border: none;
    +  border-radius: 2px;
    +  overflow: hidden;
    +  box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
    +}
    +
    +.rte-link-button {
    +  float: right;
    +}
    +
    +.hidden {
    +  visibility: hidden;
    +}
    +
    +// This is for the active buttons
    +.btn-info:hover, .btn-info:focus, .btn-info:active, .btn-info.active, .btn-info.disabled, .btn-info[disabled] {
    +    color: #ffffff;
    +    background-color: #2f96b4;
    +}
    +
    +.btn-info {
    +    color: #ffffff;
    +    text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
    +    background-color: #49afcd;
    +    background-image: -moz-linear-gradient(top, #5bc0de, #2f96b4);
    +    background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#2f96b4));
    +    background-image: -webkit-linear-gradient(top, #5bc0de, #2f96b4);
    +    background-image: -o-linear-gradient(top, #5bc0de, #2f96b4);
    +    background-image: linear-gradient(to bottom, #5bc0de, #2f96b4);
    +    background-repeat: repeat-x;
    +    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2f96b4', GradientType=0);
    +    border-color: #2f96b4 #2f96b4 #1f6377;
    +    border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
    +    filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
    +}
    diff --git a/src/client/sass/rule-list-item.scss b/src/client/sass/rule-list-item.scss
    new file mode 100644
    index 0000000..a23788d
    --- /dev/null
    +++ b/src/client/sass/rule-list-item.scss
    @@ -0,0 +1,49 @@
    +@import "_colors";
    +
    +.rli-list-item {
    +  background: #ffffff;
    +  margin: 10px 0px;
    +}
    +
    +.rli-list-avatar {
    +  margin-right: 16px;
    +  float: left;
    +  height: 40px;
    +  width: 40px;
    +  font-size: 40px;
    +  color: $grey-flannel;
    +  cursor: pointer;
    +}
    +
    +.rli-hide-overflow-text {
    +  text-overflow: ellipsis;
    +  overflow: hidden;
    +  white-space: nowrap;
    +  display: block;
    +  outline: none;
    +  max-width: 150px;
    +}
    +
    +.rli-list-alt-icon {
    +  color: $white;
    +  height: 20px;
    +  width: 20px;
    +  font-size: 20px;
    +  left: 18px;
    +  bottom: 22px;
    +  position: relative;
    +}
    +
    +.rli-icon-container {
    +  overflow: hidden;
    +  cursor: pointer;
    +  width: 60px;
    +}
    +
    +.rli-stacked-icon {
    +  position:relative;
    +}
    +
    +.rli-clickable {
    +  cursor: pointer;
    +}
    diff --git a/src/client/sass/rule-list-view.scss b/src/client/sass/rule-list-view.scss
    new file mode 100644
    index 0000000..1cb325e
    --- /dev/null
    +++ b/src/client/sass/rule-list-view.scss
    @@ -0,0 +1,36 @@
    +
    +.rlv-list {
    +  width: 100%;
    +}
    +
    +.rlv-main {
    +  margin-top: -41vh;
    +	-webkit-flex-shrink: 0;
    +	-ms-flex-negative: 0;
    +	flex-shrink: 0;
    +}
    +
    +.rlv-new-item {
    +  border: gray;
    +  border-style: dashed;
    +  cursor: pointer;
    +}
    +
    +.rlv-primary-button {
    +  margin: 12px;
    +
    +}
    +
    +.rlv-second-button {
    +  display: none;
    +}
    +
    +.rlv-button-bar {
    +  position: absolute;
    +  bottom: 10px;
    +  right: 10px;
    +
    +  &:hover>.rlv-second-button {
    +    display: block;
    +  }
    +}
    diff --git a/src/client/sass/styles.scss b/src/client/sass/styles.scss
    deleted file mode 100644
    index d67e557..0000000
    --- a/src/client/sass/styles.scss
    +++ /dev/null
    @@ -1,604 +0,0 @@
    -.hover-text:hover {
    -  text-decoration: underline;
    -}
    -
    -textarea:focus {
    -  outline: none !important;
    -  border: none;
    -  box-shadow: none;
    -}
    -
    -.branding-below {
    -  bottom: 56px;
    -  top: 0;
    -}
    -
    -.branding-text {
    -  left: 7px;
    -  position: relative;
    -  top: 3px;
    -}
    -
    -.border-bottom {
    -  border-bottom: 1px solid #cfcfcf;
    -  margin: 0px 0px 0px 0px;
    -}
    -
    -.input-full {
    -  box-sizing: border-box;
    -  box-shadow: none;
    -  border: transparent;
    -  background-color: white;
    -  font: 13px Arial;
    -  margin: 4px 0px 4px 0px;
    -  outline: none;
    -  padding: 4px 1px 4px 2px;
    -  resize: none;
    -  -webkit-box-sizing: border-box;
    -  width: 60%;
    -}
    -
    -.rest-right {
    -  float: right;
    -}
    -
    -.show-text {
    -  color: #777;
    -  font-size: 13px;
    -  padding: 0 8px 0 0;
    -}
    -
    -.select-text {
    -  cursor: pointer;
    -  -webkit-user-select: none;
    -}
    -
    -.blank-textarea {
    -  resize: none;
    -  border: hidden;
    -  width: 100%;
    -  height: 100%;
    -  font: 13px Arial;
    -}
    -
    -.fixed-button {
    -  height: 30px;
    -  padding: 10px 0px 10px 0px;
    -}
    -
    -/**** Taken from Google's Addons Stylesheet: ****/
    -
    -/*  */
    -
    -body {
    -  color: #222;
    -  font: 13px/18px arial, sans-serif;
    -  margin: 0;
    -}
    -
    -/** Google's Material Icons */
    -
    -/* Rules for sizing the icon. */
    -
    -.material-icons {
    -  &.md-11 {
    -    font-size: 11px;
    -  }
    -  &.md-13 {
    -    font-size: 13px;
    -  }
    -  &.md-18 {
    -    font-size: 18px;
    -  }
    -  &.md-24 {
    -    font-size: 24px;
    -  }
    -  &.md-36 {
    -    font-size: 36px;
    -  }
    -  &.md-48 {
    -    font-size: 48px;
    -  }
    -  &.md-dark {
    -    color: rgba(0, 0, 0, 0.54);
    -    &.md-inactive {
    -      color: rgba(0, 0, 0, 0.26);
    -    }
    -  }
    -  &.md-light {
    -    color: rgba(255, 255, 255, 1);
    -    &.md-inactive {
    -      color: rgba(255, 255, 255, 0.3);
    -    }
    -  }
    -}
    -
    -/* Rules for using icons as black on a light background. */
    -
    -/* Rules for using icons as white on a dark background. */
    -
    -/** End Material Icons */
    -
    -.sidebar {
    -  -moz-box-sizing: border-box;
    -  box-sizing: border-box;
    -  overflow-y: auto;
    -  padding: 12px;
    -  position: absolute;
    -  width: 100%;
    -}
    -
    -.bottom, .bottom-left, .button-bar {
    -  bottom: 0;
    -  position: absolute;
    -}
    -
    -.gray, .grey {
    -  color: #777;
    -}
    -
    -.button.disabled, button:disabled {
    -  background: #fff;
    -  border: 1px solid #dcdcdc;
    -  -moz-box-shadow: none;
    -  -webkit-box-shadow: none;
    -  box-shadow: none;
    -  color: #333;
    -  opacity: .5;
    -}
    -
    -input {
    -  &[type="button"]:disabled, &[type="image"]:disabled, &[type="reset"]:disabled, &[type="submit"]:disabled {
    -    background: #fff;
    -    border: 1px solid #dcdcdc;
    -    -moz-box-shadow: none;
    -    -webkit-box-shadow: none;
    -    box-shadow: none;
    -    color: #333;
    -    opacity: .5;
    -  }
    -}
    -
    -.button, button {
    -  background: -moz-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background: -ms-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background: -o-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background: linear-gradient(top, #f5f5f5, #f1f1f1);
    -  border: 1px solid #dcdcdc;
    -  -moz-border-radius: 2px;
    -  -webkit-border-radius: 2px;
    -  border-radius: 2px;
    -  -moz-box-shadow: none;
    -  -webkit-box-shadow: none;
    -  box-shadow: none;
    -  color: #333;
    -  cursor: pointer;
    -  font-family: arial, sans-serif;
    -  font-size: 11px;
    -  font-weight: bold;
    -  height: 29px;
    -  line-height: 27px;
    -  margin: 0;
    -  min-width: 72px;
    -  outline: 0;
    -  padding: 0 8px;
    -  text-align: center;
    -  white-space: nowrap;
    -}
    -
    -input {
    -  &[type="button"], &[type="image"], &[type="reset"], &[type="submit"] {
    -    background: -moz-linear-gradient(top, #f5f5f5, #f1f1f1);
    -    background: -ms-linear-gradient(top, #f5f5f5, #f1f1f1);
    -    background: -o-linear-gradient(top, #f5f5f5, #f1f1f1);
    -    background: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1);
    -    background: linear-gradient(top, #f5f5f5, #f1f1f1);
    -    border: 1px solid #dcdcdc;
    -    -moz-border-radius: 2px;
    -    -webkit-border-radius: 2px;
    -    border-radius: 2px;
    -    -moz-box-shadow: none;
    -    -webkit-box-shadow: none;
    -    box-shadow: none;
    -    color: #333;
    -    cursor: pointer;
    -    font-family: arial, sans-serif;
    -    font-size: 11px;
    -    font-weight: bold;
    -    height: 29px;
    -    line-height: 27px;
    -    margin: 0;
    -    min-width: 72px;
    -    outline: 0;
    -    padding: 0 8px;
    -    text-align: center;
    -    white-space: nowrap;
    -  }
    -}
    -
    -.button {
    -  &.action, &.blue {
    -    background: -moz-linear-gradient(top, #4d90fe, #4787ed);
    -    background: -ms-linear-gradient(top, #4d90fe, #4787ed);
    -    background: -o-linear-gradient(top, #4d90fe, #4787ed);
    -    background: -webkit-linear-gradient(top, #4d90fe, #4787ed);
    -    background: linear-gradient(top, #4d90fe, #4787ed);
    -    border: 1px solid #3079ed;
    -    color: #fff;
    -  }
    -}
    -
    -button {
    -  &.action, &.blue {
    -    background: -moz-linear-gradient(top, #4d90fe, #4787ed);
    -    background: -ms-linear-gradient(top, #4d90fe, #4787ed);
    -    background: -o-linear-gradient(top, #4d90fe, #4787ed);
    -    background: -webkit-linear-gradient(top, #4d90fe, #4787ed);
    -    background: linear-gradient(top, #4d90fe, #4787ed);
    -    border: 1px solid #3079ed;
    -    color: #fff;
    -  }
    -}
    -
    -input {
    -  &[type="button"] {
    -    &.action, &.blue {
    -      background: -moz-linear-gradient(top, #4d90fe, #4787ed);
    -      background: -ms-linear-gradient(top, #4d90fe, #4787ed);
    -      background: -o-linear-gradient(top, #4d90fe, #4787ed);
    -      background: -webkit-linear-gradient(top, #4d90fe, #4787ed);
    -      background: linear-gradient(top, #4d90fe, #4787ed);
    -      border: 1px solid #3079ed;
    -      color: #fff;
    -    }
    -  }
    -  &[type="submit"] {
    -    &.action, &.blue {
    -      background: -moz-linear-gradient(top, #4d90fe, #4787ed);
    -      background: -ms-linear-gradient(top, #4d90fe, #4787ed);
    -      background: -o-linear-gradient(top, #4d90fe, #4787ed);
    -      background: -webkit-linear-gradient(top, #4d90fe, #4787ed);
    -      background: linear-gradient(top, #4d90fe, #4787ed);
    -      border: 1px solid #3079ed;
    -      color: #fff;
    -    }
    -  }
    -}
    -
    -.button + .button, button + button, input + input {
    -  margin-left: 12px;
    -}
    -
    -/****  Google Icons Styles ****/
    -
    -.docs-icon-grid {
    -  left: 0;
    -  top: -168px;
    -}
    -
    -/* Second */
    -
    -.docs-icon {
    -  direction: ltr;
    -  text-align: left;
    -  height: 21px;
    -  overflow: hidden;
    -  vertical-align: middle;
    -  width: 21px;
    -}
    -
    -.goog-inline-block {
    -  position: relative;
    -  display: -moz-inline-box;
    -  display: inline-block;
    -}
    -
    -/* Bottom-most */
    -
    -.jfk-button {
    -  -webkit-border-radius: 2px;
    -  -moz-border-radius: 2px;
    -  border-radius: 2px;
    -  cursor: default;
    -  font-size: 11px;
    -  font-weight: bold;
    -  text-align: center;
    -  white-space: nowrap;
    -  margin-right: 16px;
    -  height: 27px;
    -  line-height: 27px;
    -  min-width: 54px;
    -  outline: 0px;
    -  padding: 0 8px;
    -}
    -
    -.jfk-button-standard {
    -  -webkit-box-shadow: none;
    -  -moz-box-shadow: none;
    -  box-shadow: none;
    -  background-color: #f5f5f5;
    -  background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background-image: -moz-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background-image: -ms-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background-image: -o-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background-image: linear-gradient(top, #f5f5f5, #f1f1f1);
    -  color: #333;
    -  border: 1px solid #dcdcdc;
    -  border: 1px solid rgba(0, 0, 0, 0.1);
    -}
    -
    -.jfk-button-narrow {
    -  min-width: 34px;
    -  padding: 0;
    -}
    -
    -.waffle-range-selection-button {
    -  background: transparent !important;
    -  border: transparent !important;
    -  cursor: pointer;
    -  margin: 0 0 0 -13px;
    -  opacity: .70;
    -  white-space: nowrap;
    -}
    -
    -/****  Google Sheets Conditional Format CSS ****/
    -
    -/* Apply to range */
    -
    -.waffle-conditionalformat-range-picker {
    -  padding: 0 18px 18px 18px;
    -}
    -
    -.waffle-conditionalformat-edit-pill-section-label {
    -  color: #646464;
    -  font-family: Arial;
    -  font-size: 12px;
    -  line-height: 14px;
    -  margin-bottom: 6px;
    -  margin-top: 18px;
    -}
    -
    -.waffle-conditionalformat-range-wrapper {
    -  color: #444;
    -  font-family: Arial;
    -  font-size: 12px;
    -}
    -
    -.waffle-range-selection-container {
    -  background: #fff;
    -  border: 1px solid #d9d9d9;
    -  border-top: 1px solid #c0c0c0;
    -  min-width: 20px;
    -  width: 100%;
    -  padding: 0px;
    -}
    -
    -.waffle-range-selection-container-focus {
    -  border: 1px solid #4d90fe;
    -  -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
    -  -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
    -  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
    -  outline: none;
    -}
    -
    -.waffle-range-selection-input {
    -  background: transparent !important;
    -  border: none !important;
    -  box-sizing: border-box;
    -  -webkit-box-shadow: none;
    -  -moz-box-shadow: none;
    -  box-shadow: none;
    -  height: 25px;
    -  margin: 0;
    -  outline: none !important;
    -  padding: 1px 8px !important;
    -  width: 100%;
    -  &:focus {
    -    background: transparent !important;
    -    border: none !important;
    -    box-sizing: border-box;
    -    -webkit-box-shadow: none;
    -    -moz-box-shadow: none;
    -    box-shadow: none;
    -    height: 25px;
    -    margin: 0;
    -    outline: none !important;
    -    padding: 1px 8px !important;
    -    width: 100%;
    -  }
    -}
    -
    -.waffle-range-selection-button-container {
    -  overflow: hidden;
    -  padding: 0 0 0 8px;
    -  text-align: right;
    -  width: 21px;
    -}
    -
    -/* Override the default addons css */
    -
    -td.waffle-row {
    -  padding: 0px;
    -}
    -
    -/* Format cells if... */
    -
    -.waffle-conditionalformat-boolean-condition-picker, .waffle-conditionalformat-gradient-format-picker {
    -  padding: 0px 18px 18px 18px;
    -}
    -
    -.goog-flat-menu-button {
    -  -webkit-border-radius: 2px;
    -  -moz-border-radius: 2px;
    -  border-radius: 2px;
    -  background-color: #f5f5f5;
    -  background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background-image: -moz-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background-image: -ms-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background-image: -o-linear-gradient(top, #f5f5f5, #f1f1f1);
    -  background-image: linear-gradient(top, #f5f5f5, #f1f1f1);
    -  border: 1px solid #dcdcdc;
    -  color: #333;
    -  cursor: default;
    -  font-size: 11px;
    -  font-weight: bold;
    -  line-height: 27px;
    -  list-style: none;
    -  margin: 0 2px;
    -  min-width: 46px;
    -  outline: none;
    -  padding: 0 18px 0 6px;
    -  text-align: center;
    -  text-decoration: none;
    -  &:hover {
    -    background-color: #f8f8f8;
    -    background-image: -webkit-linear-gradient(top, #f8f8f8, #f1f1f1);
    -    background-image: -moz-linear-gradient(top, #f8f8f8, #f1f1f1);
    -    background-image: -ms-linear-gradient(top, #f8f8f8, #f1f1f1);
    -    background-image: -o-linear-gradient(top, #f8f8f8, #f1f1f1);
    -    background-image: linear-gradient(top, #f8f8f8, #f1f1f1);
    -    -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
    -    -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
    -    box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
    -    border-color: #c6c6c6;
    -    color: #111;
    -  }
    -}
    -
    -.waffle-conditionalformat-condition-type-select .jfk-select, .waffle-conditionalformat-condition-date-select .jfk-select {
    -  margin: 0;
    -  width: 238px;
    -}
    -
    -.waffle-conditionalformat-condition-type-select .goog-flat-menu-button, .waffle-conditionalformat-condition-date-select .goog-flat-menu-button {
    -  text-align: left;
    -}
    -
    -.jfk-select .goog-flat-menu-button-caption {
    -  overflow: hidden;
    -  width: 100%;
    -}
    -
    -.goog-flat-menu-button-caption {
    -  vertical-align: top;
    -  white-space: nowrap;
    -}
    -
    -.jfk-select .goog-flat-menu-button-dropdown {
    -  background: url(//ssl.gstatic.com/ui/v1/disclosure/grey-disclosure-arrow-up-down.png) center no-repeat;
    -  border: none;
    -  height: 11px;
    -  margin-top: -4px;
    -  width: 7px;
    -}
    -
    -.goog-flat-menu-button-dropdown {
    -  border-color: #777 transparent;
    -  border-style: solid;
    -  border-width: 4px 4px 0 4px;
    -  height: 0;
    -  width: 0;
    -  position: absolute;
    -  right: 5px;
    -  top: 12px;
    -}
    -
    -.waffle-conditionalformat-arg1 {
    -  margin: 6px 6px 0 0;
    -  width: 246px;
    -}
    -
    -.jfk-textinput {
    -  -webkit-border-radius: 1px;
    -  -moz-border-radius: 1px;
    -  border-radius: 1px;
    -  border: 1px solid #d9d9d9;
    -  border-top: 1px solid #c0c0c0;
    -  font-size: 13px;
    -  height: 25px;
    -  padding: 1px 8px;
    -}
    -
    -/* Google DDLs */
    -
    -.goog-menu {
    -  z-index: 1003;
    -  -webkit-border-radius: 0;
    -  -moz-border-radius: 0;
    -  border-radius: 0;
    -  -webkit-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
    -  -moz-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
    -  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
    -  -webkit-transition: opacity 0.218s;
    -  -moz-transition: opacity 0.218s;
    -  -o-transition: opacity 0.218s;
    -  transition: opacity 0.218s;
    -  background: #fff;
    -  border: 1px solid #ccc;
    -  border: 1px solid rgba(0, 0, 0, 0.2);
    -  cursor: default;
    -  font-size: 13px;
    -  margin: 0;
    -  outline: none;
    -  padding: 6px 0;
    -  position: absolute;
    -}
    -
    -.goog-menuitem, .goog-tristatemenuitem, .goog-filterobsmenuitem {
    -  position: relative;
    -  color: #333;
    -  cursor: pointer;
    -  list-style: none;
    -  margin: 0;
    -  padding: 6px 8em 6px 30px;
    -  white-space: nowrap;
    -}
    -
    -.goog-menuseparator {
    -  border-top: 1px solid #ebebeb;
    -  margin-top: 6px;
    -  margin-bottom: 6px;
    -}
    -
    -.goog-menuitem, .goog-tristatemenuitem, .goog-filterobsmenuitem {
    -  position: relative;
    -  color: #333;
    -  cursor: pointer;
    -  list-style: none;
    -  margin: 0;
    -  padding: 6px 8em 6px 30px;
    -  white-space: nowrap;
    -}
    -
    -/* Used for the slide between divs */
    -
    -.slider {
    -  width: 300px;
    -  height: 100%;
    -  overflow: hidden;
    -  position: relative;
    -}
    -
    -.slide {
    -  float: left;
    -  width: 300px;
    -}
    -
    -.holder {
    -  width: 300%;
    -  padding: 8px 0px 0px 0px;
    -}
    -
    -/* Used for the Last Sent Column */
    -
    -/*Our custom CSS begins below*/
    -
    -.icon-right {
    -  border: none;
    -  height: 11px;
    -  width: 7px;
    -  position: absolute;
    -  right: 21px;
    -  top: 6px;
    -}
    diff --git a/src/gas/data.js b/src/gas/data.js
    new file mode 100644
    index 0000000..7ead32a
    --- /dev/null
    +++ b/src/gas/data.js
    @@ -0,0 +1,38 @@
    +/**
    + * This file handles all data storage and retrieval.
    + *
    + */
    +
    +
    +/**
    + * Saves value, indexable by key, in the data store.
    + *
    + * @param {String} key The key of the data
    + * @param {String} value The data to store.
    + */
    +function save(key, value) {
    +  var prop = PropertiesService.getDocumentProperties();
    +
    +  if (prop === null) {
    +    throw 'Error: no document could be found.';
    +  }
    +
    +  prop.setProperty(key, value);
    +}
    +
    +
    +/**
    + * Returns the data associated with key or null if nothing is found.
    + *
    + * @param {String} key The key the required string is indexed on.
    + * @return {?String} The data store
    + */
    +function load(key) {
    +  var prop = PropertiesService.getDocumentProperties();
    +
    +  if (prop === null) {
    +    throw 'Error: no document could be found.';
    +  }
    +
    +  return prop.getProperty(key);
    +}
    diff --git a/src/gas/email.js b/src/gas/email.js
    new file mode 100644
    index 0000000..21767fc
    --- /dev/null
    +++ b/src/gas/email.js
    @@ -0,0 +1,198 @@
    +
    +
    +var RuleTypes = {
    +  INSTANT: 'INSTANT',
    +  TRIGGER: 'TRIGGER'
    +};
    +
    +
    +/**
    + * Sends an email based upon an EmailRule. Tags are swapped out.
    + * Handles to, subject and body fields.
    + *
    + * @param {String[]} headerRow An array of header values.
    + * @param {String[]} row An array of row values.
    + * @param {Object} rule The rule used to send the email. See the client-side object EmailRule for info.
    + * @return {Boolean} true if the email was sent, false otherwise.
    + */
    +function sendBasicEmail(headerRow, row, rule) {
    +  var combinedObj = {};
    +  for (var j = 0; j < headerRow.length; j++) {
    +    combinedObj[headerRow[j]] = row[j];
    +  }
    +
    +  // Convert <<>> tags to actual text.
    +  var to = replaceTags(rule.to, combinedObj);
    +  var subject = replaceTags(rule.subject, combinedObj);
    +  var body = replaceTags(rule.body, combinedObj);
    +
    +  log('Sending email to ' + to);
    +  GmailApp.sendEmail(to, subject, body);
    +
    +  return true;
    +}
    +
    +
    +/**
    + * Sends an email based upon an EmailRule. Tags are swapped out.
    + * Handles to, subject, body, sendColumn and timestampColumn fields.
    + * This is used for TRIGGER type rules.
    + *
    + * @param {String[]} headerRow An array of header values.
    + * @param {String[]} row An array of row values.
    + * @param {Object} rule The rule used to send the email. See the client-side object EmailRule for info.
    + * @return {Boolean} true if the email was sent, false otherwise.
    + */
    +function sendConditionalEmail(headerRow, row, rule) {
    +  var combinedObj = {};
    +  for (var j = 0; j < headerRow.length; j++) {
    +    combinedObj[headerRow[j]] = row[j];
    +  }
    +
    +  // Convert <<>> tags to actual text.
    +  var to = replaceTags(rule.to, combinedObj);
    +  var subject = replaceTags(rule.subject, combinedObj);
    +  var body = replaceTags(rule.body, combinedObj);
    +  var sendColumn = replaceTags(rule.sendColumn, combinedObj);
    +
    +  if (sendColumn.toLowerCase() === 'true') {
    +    log('Sending email to ' + to);
    +    GmailApp.sendEmail(to, subject, body);
    +
    +    return true;
    +  }
    +
    +  return false;
    +}
    +
    +
    +/**
    + * Handles all trigger rules. For each rule, all rows will be iterated through.
    + *
    + */
    +function sendManyEmails() {
    +  log('Starting rules...');
    +  var rules = getRules();
    +
    +  // Validate each rule for each row
    +  var ss = SpreadsheetApp.openById(load(PROPERTY_SS_ID));
    +
    +  log(JSON.stringify(rules));
    +  log('For sheet: ' + ss.getUrl());
    +
    +  for (var i = 0; i < rules.rules.length; i++) {
    +    var rule = rules.rules[i];
    +
    +    if (rule.ruleType === RuleTypes.TRIGGER) {
    +      triggerEmail(ss, rule);
    +    }
    +  }
    +
    +  log('Ending rules...');
    +}
    +
    +
    +function triggerEmail(ss, rule) {
    +  log('Starting trigger rule...');
    +  log(rule);
    +  if (!validateRule(rule)) {
    +    return;
    +  }
    +
    +  var sheet = ss.getSheetByName(rule.sheet);
    +  var range = sheet.getDataRange();
    +  var header = getHeaderStrings(rule);
    +
    +  for (var i = parseInt(rule.headerRow); i < range.getNumRows(); i++) {
    +    var row = getValues(sheet, i);
    +
    +    // We only timestamp when the email successfully sends.
    +    if (sendConditionalEmail(header, row, rule)) {
    +      var dateColumn = rule.timestampColumn.replace(/(<<|>>)/g, '');
    +      var currentDate = new Date();
    +      var datetime = (currentDate.getMonth() + 1) + '/' +
    +              currentDate.getDate() + '/' +
    +              currentDate.getFullYear() + ' ' +
    +              currentDate.getHours() + ':' +
    +              currentDate.getMinutes() + ':' +
    +              currentDate.getSeconds();
    +
    +      var cell = getCell(sheet, dateColumn, i);
    +      cell.setValue(datetime);
    +    }
    +  }
    +
    +  log('Ending trigger rule...');
    +}
    +
    +
    +function instantEmail(rule) {
    +
    +  log('Starting instant email...');
    +  log(rule);
    +  if (!validateRule(rule)) {
    +    return;
    +  }
    +
    +  // Validate each rule for each row
    +  var ss = SpreadsheetApp.openById(load(PROPERTY_SS_ID));
    +  var sheet = ss.getSheetByName(rule.sheet);
    +  var range = sheet.getDataRange();
    +  var header = getHeaderStrings(rule);
    +
    +  log('For sheet: ' + ss.getUrl());
    +
    +  for (var i = parseInt(rule.headerRow); i < range.getNumRows(); i++) {
    +    var row = getValues(sheet, i);
    +
    +    try {
    +      sendBasicEmail(header, row, rule);
    +    }
    +    catch (e) {
    +      log(e);
    +    }
    +
    +  }
    +
    +  log('Ending instant email...');
    +}
    +
    +
    +function validateRule(rule) {
    +  if (rule.ruleType == null) {
    +    log('EmailRule config is missing "ruleType".');
    +    return false;
    +  }
    +  if (rule.to == null) {
    +    log('EmailRule config is missing "to".');
    +    return false;
    +  }
    +  if (rule.headerRow == null) {
    +    log('EmailRule config is missing "headerRow".');
    +    return false;
    +  }
    +  if (rule.sheet == null) {
    +    log('EmailRule config is missing "sheet".');
    +    return false;
    +  }
    +  if (rule.subject == null) {
    +    log('EmailRule config is missing "subject".');
    +    return false;
    +  }
    +  if (rule.body == null) {
    +    log('EmailRule config is missing "body".');
    +    return false;
    +  }
    +  if (rule.ruleType === RuleTypes.TRIGGER &&
    +      rule.sendColumn == null) {
    +    log('EmailRule config is missing "sendColumn".');
    +    return false;
    +  }
    +  if (rule.ruleType === RuleTypes.TRIGGER &&
    +      rule.timestampColumn == null) {
    +    log('EmailRule config is missing "timestampColumn".');
    +    return false;
    +  }
    +
    +  return true;
    +}
    diff --git a/src/gas/logger.js b/src/gas/logger.js
    new file mode 100644
    index 0000000..a4c1f40
    --- /dev/null
    +++ b/src/gas/logger.js
    @@ -0,0 +1,21 @@
    +
    +// This is the logging spreadsheet I'm using.
    +var logSheet;
    +var logSheetID = '1IBQrb0zET4Zh_KpdpTGVww_y15SGTOmo8B9ZM9ZXVzg';
    +var maxRows = 3000;
    +var DEBUG = true;
    +
    +function log(text) {
    +  if (DEBUG) {
    +    if (logSheet === undefined) {
    +      logSheet = SpreadsheetApp.openById(logSheetID);
    +    }
    +    if (logSheet.getLastRow() > maxRows) {
    +      var sheet = logSheet.getSheets()[0];
    +      sheet.getRange(2, 1, logSheet.getLastRow() - 1, logSheet.getLastColumn()).clear();
    +    }
    +
    +    logSheet.appendRow([new Date().toString().slice(0, -15), text, Session.getActiveUser().getEmail(), MAILMAN_VERSION]);
    +  }
    +
    +}