diff --git a/examples/N1-Composer-Translate/lib/main.cjsx b/examples/N1-Composer-Translate/lib/main.cjsx index 882dfe4c1..68bd28641 100644 --- a/examples/N1-Composer-Translate/lib/main.cjsx +++ b/examples/N1-Composer-Translate/lib/main.cjsx @@ -9,7 +9,7 @@ request = require 'request' {React, ComponentRegistry, - QuotedHTMLParser, + QuotedHTMLTransformer, DraftStore} = require 'nylas-exports' {Menu, RetinaImg, @@ -83,7 +83,7 @@ class TranslateButton extends React.Component # session = DraftStore.sessionForClientId(@props.draftClientId).then (session) => draftHtml = session.draft().body - text = QuotedHTMLParser.removeQuotedHTML(draftHtml) + text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml) query = key: YandexTranslationKey @@ -99,7 +99,7 @@ class TranslateButton extends React.Component # The new text of the draft is our translated response, plus any quoted text # that we didn't process. translated = json.text.join('') - translated = QuotedHTMLParser.appendQuotedHTML(translated, draftHtml) + translated = QuotedHTMLTransformer.appendQuotedHTML(translated, draftHtml) # To update the draft, we add the new body to it's session. The session object # automatically marshalls changes to the database and ensures that others accessing diff --git a/examples/N1-Quick-Schedule/docs/main.html b/examples/N1-Quick-Schedule/docs/main.html index d9bec5d0b..016653652 100644 --- a/examples/N1-Quick-Schedule/docs/main.html +++ b/examples/N1-Quick-Schedule/docs/main.html @@ -60,7 +60,7 @@ your availabilities to schedule an appointment with you.

{ComponentRegistry, DatabaseStore, DraftStore, - QuotedHTMLParser, + QuotedHTMLTransformer, Event} = require 'nylas-exports' url = require('url') @@ -315,8 +315,7 @@ subscribing to events, release them here.

    DraftStore.sessionForClientId(draftClientId).then (session) =>
       draftHtml = session.draft().body
-      text = QuotedHTMLParser.removeQuotedHTML(draftHtml)
- + text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml) @@ -333,8 +332,7 @@ subscribing to events, release them here.

      console.log(@_createBlock(events,eventData));
       text += "<br/>"+@_createBlock(events,eventData)+"<br/>";
 
-      newDraftHtml = QuotedHTMLParser.appendQuotedHTML(text, draftHtml)
- + newDraftHtml = QuotedHTMLTransformer.appendQuotedHTML(text, draftHtml) diff --git a/examples/N1-Quick-Schedule/lib/main.cjsx b/examples/N1-Quick-Schedule/lib/main.cjsx index 01087d8eb..89a0f22a8 100644 --- a/examples/N1-Quick-Schedule/lib/main.cjsx +++ b/examples/N1-Quick-Schedule/lib/main.cjsx @@ -7,7 +7,7 @@ {ComponentRegistry, DatabaseStore, DraftStore, - QuotedHTMLParser, + QuotedHTMLTransformer, ExtensionRegistry, Event} = require 'nylas-exports' @@ -131,12 +131,12 @@ module.exports = # Obtain the session for the current draft. DraftStore.sessionForClientId(draftClientId).then (session) => draftHtml = session.draft().body - text = QuotedHTMLParser.removeQuotedHTML(draftHtml) + text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml) # add the block text += "
"+@_createBlock(events,eventData)+"
" - newDraftHtml = QuotedHTMLParser.appendQuotedHTML(text, draftHtml) + newDraftHtml = QuotedHTMLTransformer.appendQuotedHTML(text, draftHtml) # update the draft session.changes.add(body: newDraftHtml) diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 5d2859c23..2e405025f 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -9,7 +9,7 @@ React = require 'react' ContactStore, AccountStore, FileUploadStore, - QuotedHTMLParser, + QuotedHTMLTransformer, FileDownloadStore, ExtensionRegistry} = require 'nylas-exports' @@ -327,14 +327,14 @@ class ComposerView extends React.Component _removeQuotedText: (html) => if @state.showQuotedText then return html - else return QuotedHTMLParser.removeQuotedHTML(html) + else return QuotedHTMLTransformer.removeQuotedHTML(html) _showQuotedText: (html) => if @state.showQuotedText then return html - else return QuotedHTMLParser.appendQuotedHTML(html, @state.body) + else return QuotedHTMLTransformer.appendQuotedHTML(html, @state.body) _renderQuotedTextControl: -> - if QuotedHTMLParser.hasQuotedHTML(@state.body) + if QuotedHTMLTransformer.hasQuotedHTML(@state.body) text = if @state.showQuotedText then "Hide" else "Show" •••{text} previous @@ -712,7 +712,7 @@ class ComposerView extends React.Component Actions.sendDraft(@props.draftClientId) _mentionsAttachment: (body) => - body = QuotedHTMLParser.removeQuotedHTML(body.toLowerCase().trim()) + body = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim()) return body.indexOf("attach") >= 0 _destroyDraft: => diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index bf5fd83f9..2244845da 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -122,7 +122,7 @@ body.platform-win32 { padding-top: 12px; } - input, textarea, .composer-participant-field { + input, textarea { color: @text-color; position: relative; display: block; @@ -313,6 +313,7 @@ body.platform-win32 { flex-shrink: 0; border-bottom: 1px solid @border-color-divider; min-height: 49px; + color: @text-color; .button-dropdown { margin-left: 10px; diff --git a/internal_packages/message-list/lib/email-frame.cjsx b/internal_packages/message-list/lib/email-frame.cjsx index 76c6c4396..204b21258 100644 --- a/internal_packages/message-list/lib/email-frame.cjsx +++ b/internal_packages/message-list/lib/email-frame.cjsx @@ -1,7 +1,7 @@ React = require 'react' _ = require "underscore" {EventedIFrame} = require 'nylas-component-kit' -{QuotedHTMLParser} = require 'nylas-exports' +{QuotedHTMLTransformer} = require 'nylas-exports' EmailFrameStylesStore = require './email-frame-styles-store' @@ -82,7 +82,7 @@ class EmailFrame extends React.Component if @props.showQuotedText @props.content else - QuotedHTMLParser.removeQuotedHTML(@props.content, keepIfWholeBodyIsQuote: true) + QuotedHTMLTransformer.removeQuotedHTML(@props.content, keepIfWholeBodyIsQuote: true) module.exports = EmailFrame diff --git a/internal_packages/message-list/lib/message-item-body.cjsx b/internal_packages/message-list/lib/message-item-body.cjsx index c7b3e2f0b..b3c0fa8cb 100644 --- a/internal_packages/message-list/lib/message-item-body.cjsx +++ b/internal_packages/message-list/lib/message-item-body.cjsx @@ -4,7 +4,7 @@ EmailFrame = require './email-frame' {Utils, MessageUtils, MessageBodyProcessor, - QuotedHTMLParser, + QuotedHTMLTransformer, FileDownloadStore} = require 'nylas-exports' {InjectedComponentSet} = require 'nylas-component-kit' @@ -47,7 +47,7 @@ class MessageItemBody extends React.Component _renderQuotedTextControl: => - return null unless QuotedHTMLParser.hasQuotedHTML(@props.message.body) + return null unless QuotedHTMLTransformer.hasQuotedHTML(@props.message.body) text = if @state.showQuotedText then "Hide" else "Show" •••{text} previous diff --git a/internal_packages/message-list/lib/message-item.cjsx b/internal_packages/message-list/lib/message-item.cjsx index fd4f0c609..aa4a7eb35 100644 --- a/internal_packages/message-list/lib/message-item.cjsx +++ b/internal_packages/message-list/lib/message-item.cjsx @@ -12,7 +12,7 @@ MessageControls = require './message-controls' AccountStore, MessageStore, MessageBodyProcessor, - QuotedHTMLParser, + QuotedHTMLTransformer, ComponentRegistry, FileDownloadStore} = require 'nylas-exports' {RetinaImg, diff --git a/internal_packages/message-list/spec/message-item-spec.cjsx b/internal_packages/message-list/spec/message-item-spec.cjsx index 0c609c408..551f3df4b 100644 --- a/internal_packages/message-list/spec/message-item-spec.cjsx +++ b/internal_packages/message-list/spec/message-item-spec.cjsx @@ -7,7 +7,7 @@ ReactTestUtils = React.addons.TestUtils File, Thread, Utils, - QuotedHTMLParser, + QuotedHTMLTransformer, FileDownloadStore, MessageBodyProcessor} = require "nylas-exports" diff --git a/keymaps/base.cson b/keymaps/base.cson index 63463b1d7..d944486a7 100644 --- a/keymaps/base.cson +++ b/keymaps/base.cson @@ -27,6 +27,7 @@ 'cmdctrl-x': 'core:cut' 'cmdctrl-c': 'core:copy' 'cmdctrl-v': 'core:paste' + 'cmdctrl-shift-v': 'core:paste-and-match-style' 'cmdctrl-a': 'core:select-all' 'up' : 'core:previous-item' diff --git a/menus/darwin.cson b/menus/darwin.cson index de3c4ff79..0adb7e5e3 100644 --- a/menus/darwin.cson +++ b/menus/darwin.cson @@ -42,6 +42,7 @@ { label: 'Cut', command: 'core:cut' } { label: 'Copy', command: 'core:copy' } { label: 'Paste', command: 'core:paste' } + { label: 'Paste and Match Style', command: 'core:paste-and-match-style' } { label: 'Select All', command: 'core:select-all' } ] } diff --git a/menus/linux.cson b/menus/linux.cson index d785297eb..1c33cd93b 100644 --- a/menus/linux.cson +++ b/menus/linux.cson @@ -22,6 +22,7 @@ { label: '&Cut', command: 'core:cut' } { label: 'C&opy', command: 'core:copy' } { label: '&Paste', command: 'core:paste' } + { label: 'Paste and Match Style', command: 'core:paste-and-match-style' } { label: 'Select &All', command: 'core:select-all' } { type: 'separator' } { label: 'Preferences', command: 'application:open-preferences' } diff --git a/menus/win32.cson b/menus/win32.cson index 79b7ac020..1cbd8217e 100644 --- a/menus/win32.cson +++ b/menus/win32.cson @@ -9,6 +9,7 @@ { label: 'Cu&t', command: 'core:cut' } { label: '&Copy', command: 'core:copy' } { label: '&Paste', command: 'core:paste' } + { label: 'Paste and Match Style', command: 'core:paste-and-match-style' } { label: 'Select &All', command: 'core:select-all' } ] } diff --git a/spec/components/clipboard-service-spec.coffee b/spec/components/clipboard-service-spec.coffee index 9f35bf690..d13a491b9 100644 --- a/spec/components/clipboard-service-spec.coffee +++ b/spec/components/clipboard-service-spec.coffee @@ -1,18 +1,21 @@ ClipboardService = require '../../src/components/contenteditable/clipboard-service' +{InlineStyleTransformer, SanitizeTransformer} = require 'nylas-exports' +fs = require 'fs' describe "ClipboardService", -> beforeEach -> @onFilePaste = jasmine.createSpy('onFilePaste') @clipboardService = new ClipboardService + spyOn(document, 'execCommand') - describe "when html and plain text parts are present", -> + describe "when both html and plain text parts are present", -> beforeEach -> @mockEvent = preventDefault: jasmine.createSpy('preventDefault') clipboardData: - getData: -> - return 'This is text' if 'text/html' - return 'This is plain text' if 'text/plain' + getData: (mimetype) -> + return 'This is text' if mimetype is 'text/html' + return 'This is plain text' if mimetype is 'text/plain' return null items: [{ kind: 'string' @@ -24,144 +27,95 @@ describe "ClipboardService", -> getAsString: -> 'This is plain text' }] - it "should sanitize the HTML string and call insertHTML", -> - spyOn(document, 'execCommand') - spyOn(@clipboardService, '_sanitizeInput').andCallThrough() + it "should choose to insert the HTML representation", -> + spyOn(@clipboardService, '_sanitizeHTMLInput').andCallFake (input) => + Promise.resolve(input) runs -> @clipboardService.onPaste(@mockEvent) waitsFor -> document.execCommand.callCount > 0 runs -> - expect(@clipboardService._sanitizeInput).toHaveBeenCalledWith('This is text', 'text/html') [command, a, html] = document.execCommand.mostRecentCall.args expect(command).toEqual('insertHTML') expect(html).toEqual('This is text') - describe "when html and plain text parts are present", -> + describe "when only plain text is present", -> beforeEach -> @mockEvent = preventDefault: jasmine.createSpy('preventDefault') clipboardData: - getData: -> - return 'This is plain text' if 'text/plain' + getData: (mimetype) -> + return 'This is plain text\nAnother line Hello World' if mimetype is 'text/plain' return null items: [{ kind: 'string' type: 'text/plain' - getAsString: -> 'This is plain text' + getAsString: -> 'This is plain text\nAnother line Hello World' }] - it "should sanitize the plain text string and call insertHTML", -> - spyOn(document, 'execCommand') - spyOn(@clipboardService, '_sanitizeInput').andCallThrough() - + it "should convert the plain text to HTML and call insertHTML", -> runs -> @clipboardService.onPaste(@mockEvent) waitsFor -> document.execCommand.callCount > 0 runs -> - expect(@clipboardService._sanitizeInput).toHaveBeenCalledWith('This is plain text', 'text/html') [command, a, html] = document.execCommand.mostRecentCall.args expect(command).toEqual('insertHTML') - expect(html).toEqual('This is plain text') + expect(html).toEqual('This is plain text
Another line  Hello  World') - describe "sanitization", -> - tests = [ - { - in: "" - sanitizedAsHTML: "" - sanitizedAsPlain: "" - }, - { - in: "Hello World" - sanitizedAsHTML: "Hello World" - sanitizedAsPlain: "Hello World" - }, - { - in: " Hello World" - # Should collapse to 1 space when rendered - sanitizedAsHTML: " Hello World" - # Preserving 2 spaces - sanitizedAsPlain: "  Hello  World" - }, - { - in: " Hello World" - sanitizedAsHTML: " Hello World" - # Preserving 3 spaces - sanitizedAsPlain: "   Hello   World" - }, - { - in: " Hello World" - sanitizedAsHTML: " Hello World" - # Preserving 4 spaces - sanitizedAsPlain: "    Hello    World" - }, - { - in: "Hello\nWorld" - sanitizedAsHTML: "Hello
World" - # Convert newline to br - sanitizedAsPlain: "Hello
World" - }, - { - in: "Hello\rWorld" - sanitizedAsHTML: "Hello
World" - # Convert carriage return to br - sanitizedAsPlain: "Hello
World" - }, - { + describe "HTML sanitization", -> + beforeEach -> + spyOn(InlineStyleTransformer, 'run').andCallThrough() + spyOn(SanitizeTransformer, 'run').andCallThrough() + + it "should inline CSS styles and run the standard permissive HTML sanitizer", -> + input = "HTML HERE" + @clipboardService._sanitizeHTMLInput(input) + advanceClock() + expect(InlineStyleTransformer.run).toHaveBeenCalledWith(input) + advanceClock() + expect(SanitizeTransformer.run).toHaveBeenCalledWith(input, SanitizeTransformer.Preset.Permissive) + + it "should replace two or more
s in a row", -> + tests = [{ in: "Hello\n\n\nWorld" - # Never have more than 2 br's in a row - sanitizedAsHTML: "Hello

World" - # Convert multiple newlines to same number of brs - sanitizedAsPlain: "Hello


World" - }, - { - in: " Foo Bar
Baz
" - # Strip bad tags - sanitizedAsHTML: " Foo Bar Baz" - # HTML encode tags for literal display - sanitizedAsPlain: "<style>Yo</style> Foo Bar <div>Baz</div>" - }, - { - in: " Yo < script>Boo! < / script >" - # Strip non white-list tags and encode malformed ones. - sanitizedAsHTML: " Yo < script>Boo! < / script >" - # HTML encode tags for literal display - sanitizedAsPlain: "<script>Bah</script> Yo < script>Boo! < / script >" - }, - { - in: """ - - - - - - - - - - - - - - """ - # Strip non white-list tags and encode malformed ones. - sanitizedAsHTML: "" - # HTML encode tags for literal display - sanitizedAsPlain: "<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<title></title>
<meta name="Generator" content="Cocoa HTML Writer">
<meta name="CocoaVersion" content="1265.21">
<style type="text/css">
li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}
ul.ul1 {list-style-type: disc}
</style>
</head>
<body>
<ul class="ul1">
<li class="li1"><b>Packet pickup: </b>I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...</li>
</ul>
</body>
</html>" - } - ] - - it "sanitizes plain text properly", -> + out: "Hello

World" + },{ + in: "Hello



World" + out: "Hello

World" + }] for test in tests - expect(@clipboardService._sanitizeInput(test.in, "text/plain")).toBe test.sanitizedAsPlain + waitsForPromise => + @clipboardService._sanitizeHTMLInput(test.in).then (out) -> + expect(out).toBe(test.out) - it "sanitizes html text properly", -> + + it "should remove all leading and trailing
s from the text", -> + tests = [{ + in: "

Hello
World" + out: "Hello
World" + },{ + in: "

Hello



" + out: "Hello" + }] for test in tests - expect(@clipboardService._sanitizeInput(test.in, "text/html")).toBe test.sanitizedAsHTML + waitsForPromise => + @clipboardService._sanitizeHTMLInput(test.in).then (out) -> + expect(out).toBe(test.out) + + # Unfortunately, it doesn't seem we can do real IPC (to `juice` in the main process) + # so these tests are non-functional. + xdescribe "real-world examples", -> + it "should produce the correct output", -> + scenarios = [] + fixtures = path.resolve('./spec/fixtures/paste') + for filename in fs.readdirSync(fixtures) + if filename[-8..-1] is '-in.html' + scenarios.push + in: fs.readFileSync(path.join(fixtures, filename)).toString() + out: fs.readFileSync(path.join(fixtures, "#{filename[0..-9]}-out.html")).toString() + + scenarios.forEach (scenario) => + @clipboardService._sanitizeHTMLInput(scenario.in).then (out) -> + expect(out).toBe(scenario.out) diff --git a/spec/fixtures/paste/excel-paste-in.html b/spec/fixtures/paste/excel-paste-in.html new file mode 100644 index 000000000..00e4461aa --- /dev/null +++ b/spec/fixtures/paste/excel-paste-in.html @@ -0,0 +1,279 @@ +" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Pros +vs.- Cons -
ItemImportanceItemImportance
Good2Bad2
Cheap4Expensive3
Fast1Slow1
+ + + + +" \ No newline at end of file diff --git a/spec/fixtures/paste/excel-paste-out.html b/spec/fixtures/paste/excel-paste-out.html new file mode 100644 index 000000000..00e4461aa --- /dev/null +++ b/spec/fixtures/paste/excel-paste-out.html @@ -0,0 +1,279 @@ +" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Pros +vs.- Cons -
ItemImportanceItemImportance
Good2Bad2
Cheap4Expensive3
Fast1Slow1
+ + + + +" \ No newline at end of file diff --git a/spec/fixtures/paste/word-paste-in.html b/spec/fixtures/paste/word-paste-in.html new file mode 100644 index 000000000..419e0dbc8 --- /dev/null +++ b/spec/fixtures/paste/word-paste-in.html @@ -0,0 +1,1306 @@ + + + + + + + + + + + + + + + + + + + + + +

+ +

 

+ +

 

+ +

For immediate release

+ +

 

+ +

Media contact:

+ +

Christine Dunn

+ +

ArcPoint Strategic Communications

+ +

617.484.1660, x101

+ +

cdunn@arcpointstrategy.com

+ +

 

+ +

 

+ +

Optio +Labs Announces the Acquisition of Oculis Labs, and Names Oculis Founder, Dr. +Bill Anderson, as Chief Product Officer

+ +

 

+ +

 

+ +

Baltimore (April 8, 2015) – Optio Labs, which creates +technology products that make mobile devices more secure, announced +that it has purchased Maryland-based security company Oculis Labs, and its CEO, +Dr. Bill Anderson, will be joining the company as Chief Product Officer. Oculis +is developer of the award-winning products PrivateEye and Chameleon.

+ + + + + \ No newline at end of file diff --git a/spec/fixtures/paste/word-paste-out.html b/spec/fixtures/paste/word-paste-out.html new file mode 100644 index 000000000..419e0dbc8 --- /dev/null +++ b/spec/fixtures/paste/word-paste-out.html @@ -0,0 +1,1306 @@ + + + + + + + + + + + + + + + + + + + + + +

+ +

 

+ +

 

+ +

For immediate release

+ +

 

+ +

Media contact:

+ +

Christine Dunn

+ +

ArcPoint Strategic Communications

+ +

617.484.1660, x101

+ +

cdunn@arcpointstrategy.com

+ +

 

+ +

 

+ +

Optio +Labs Announces the Acquisition of Oculis Labs, and Names Oculis Founder, Dr. +Bill Anderson, as Chief Product Officer

+ +

 

+ +

 

+ +

Baltimore (April 8, 2015) – Optio Labs, which creates +technology products that make mobile devices more secure, announced +that it has purchased Maryland-based security company Oculis Labs, and its CEO, +Dr. Bill Anderson, will be joining the company as Chief Product Officer. Oculis +is developer of the award-winning products PrivateEye and Chameleon.

+ + + + + \ No newline at end of file diff --git a/spec/quoted-html-parser-spec.coffee b/spec/quoted-html-transformer-spec.coffee similarity index 90% rename from spec/quoted-html-parser-spec.coffee rename to spec/quoted-html-transformer-spec.coffee index aa7a697a7..ed5070d8a 100644 --- a/spec/quoted-html-parser-spec.coffee +++ b/spec/quoted-html-transformer-spec.coffee @@ -1,22 +1,22 @@ _ = require('underscore') fs = require('fs') path = require 'path' -QuotedHTMLParser = require('../src/services/quoted-html-parser') +QuotedHTMLTransformer = require('../src/services/quoted-html-transformer') -describe "QuotedHTMLParser", -> +describe "QuotedHTMLTransformer", -> readFile = (fname) -> emailPath = path.resolve(__dirname, 'fixtures', 'emails', fname) return fs.readFileSync(emailPath, 'utf8') hideQuotedHTML = (fname) -> - return QuotedHTMLParser.hideQuotedHTML(readFile(fname)) + return QuotedHTMLTransformer.hideQuotedHTML(readFile(fname)) removeQuotedHTML = (fname, opts={}) -> - return QuotedHTMLParser.removeQuotedHTML(readFile(fname), opts) + return QuotedHTMLTransformer.removeQuotedHTML(readFile(fname), opts) numQuotes = (html) -> - re = new RegExp(QuotedHTMLParser.annotationClass, 'g') + re = new RegExp(QuotedHTMLTransformer.annotationClass, 'g') html.match(re)?.length ? 0 [1..16].forEach (n) -> @@ -260,23 +260,23 @@ describe "QuotedHTMLParser", -> it 'works with these manual test cases', -> for {before, after} in tests opts = keepIfWholeBodyIsQuote: true - test = clean(QuotedHTMLParser.removeQuotedHTML(before, opts)) + test = clean(QuotedHTMLTransformer.removeQuotedHTML(before, opts)) expect(test).toEqual clean(after) it 'removes all trailing
tags except one', -> input0 = "hello world

foolololol
" expect0 = "hello world
" - expect(QuotedHTMLParser.removeQuotedHTML(input0)).toEqual expect0 + expect(QuotedHTMLTransformer.removeQuotedHTML(input0)).toEqual expect0 it 'preserves
tags in the middle and only chops off tail', -> input0 = "hello

world

foolololol
" expect0 = "hello

world
" - expect(QuotedHTMLParser.removeQuotedHTML(input0)).toEqual expect0 + expect(QuotedHTMLTransformer.removeQuotedHTML(input0)).toEqual expect0 # We have a little utility method that you can manually uncomment to - # generate what the current iteration of the QuotedHTMLParser things the + # generate what the current iteration of the QuotedHTMLTransformer things the # `removeQuotedHTML` should look like. These can be manually inspected in # a browser before getting their filename changed to # `email_#{n}_stripped.html". The actually tests will run the current @@ -284,11 +284,10 @@ describe "QuotedHTMLParser", -> # anything has changed in the parser. # # It's inside of the specs here instaed of its own script because the - # `QuotedHTMLParser` needs Electron booted up in order to work because + # `QuotedHTMLTransformer` needs Electron booted up in order to work because # of the DOMParser. xit "Run this simple funciton to generate output files", -> [16].forEach (n) -> - newHTML = QuotedHTMLParser.removeQuotedHTML(readFile("email_#{n}.html")) + newHTML = QuotedHTMLTransformer.removeQuotedHTML(readFile("email_#{n}.html")) outPath = path.resolve(__dirname, 'fixtures', 'emails', "email_#{n}_raw_stripped.html") fs.writeFileSync(outPath, newHTML) - diff --git a/spec/quoted-plain-text-parser-spec.coffee b/spec/quoted-plain-text-transformer-spec.coffee similarity index 99% rename from spec/quoted-plain-text-parser-spec.coffee rename to spec/quoted-plain-text-transformer-spec.coffee index 3776eabe1..c69711bcc 100644 --- a/spec/quoted-plain-text-parser-spec.coffee +++ b/spec/quoted-plain-text-transformer-spec.coffee @@ -4,7 +4,7 @@ fs = require('fs') path = require 'path' _ = require('underscore') -QuotedPlainTextParser = require('../src/services/quoted-plain-text-parser') +QuotedPlainTextParser = require('../src/services/quoted-plain-text-transformer') getParsedEmail = (name, format="plain") -> data = getRawEmail(name, format) diff --git a/spec/services/inline-style-transformer-spec.coffee b/spec/services/inline-style-transformer-spec.coffee new file mode 100644 index 000000000..d64069672 --- /dev/null +++ b/spec/services/inline-style-transformer-spec.coffee @@ -0,0 +1,67 @@ +InlineStyleTransformer = require '../../src/services/inline-style-transformer' +{ipcRenderer} = require 'electron' + +describe "InlineStyleTransformer", -> + describe "run", -> + beforeEach -> + spyOn(ipcRenderer, 'send') + spyOn(InlineStyleTransformer, '_injectUserAgentStyles').andCallFake (input) => + return input + InlineStyleTransformer._inlineStylePromises = {} + + it "should return a Promise", -> + expect(InlineStyleTransformer.run("asd") instanceof Promise).toBe(true) + + it "should resolve immediately if the html is empty", -> + result = InlineStyleTransformer.run("") + expect(result.isResolved()).toBe(true) + + it "should resolve immediately if there is no + + """) + expect(ipcRenderer.send.mostRecentCall.args[1].html).toEqual(""" + + + """) + + it "should add user agent styles", -> + InlineStyleTransformer.run("""Other content goes here""") + expect(InlineStyleTransformer._injectUserAgentStyles).toHaveBeenCalled() + + it "should fire inline-style-parse to the main process", -> + InlineStyleTransformer.run("""Other content goes here""") + expect(ipcRenderer.send).toHaveBeenCalled() + expect(ipcRenderer.send.mostRecentCall.args[0]).toEqual('inline-style-parse') diff --git a/spec/stores/draft-store-spec.coffee b/spec/stores/draft-store-spec.coffee index 4b6eee14a..fd628c942 100644 --- a/spec/stores/draft-store-spec.coffee +++ b/spec/stores/draft-store-spec.coffee @@ -13,6 +13,9 @@ SoundRegistry = require '../../src/sound-registry' Actions = require '../../src/flux/actions' Utils = require '../../src/flux/models/utils' +InlineStyleTransformer = require '../../src/services/inline-style-transformer' +SanitizeTransformer = require '../../src/services/sanitize-transformer' + {ipcRenderer} = require 'electron' _ = require 'underscore' @@ -40,9 +43,8 @@ describe "DraftStore", -> describe "creating drafts", -> beforeEach -> - spyOn(DraftStore, "_sanitizeBody").andCallThrough() - spyOn(DraftStore, "_onInlineStylesResult").andCallThrough() - spyOn(DraftStore, "_convertToInlineStyles").andCallThrough() + spyOn(DraftStore, "_prepareBodyForQuoting").andCallFake (body) -> + Promise.resolve(body) spyOn(ipcRenderer, "send").andCallFake (message, body) -> if message is "inline-style-parse" # There needs to be a defer block in here so the promise @@ -181,11 +183,7 @@ describe "DraftStore", -> expect(@model.replyToMessageId).toEqual(fakeMessage1.id) it "should sanitize the HTML", -> - expect(DraftStore._sanitizeBody).toHaveBeenCalled() - - it "should not call the style inliner when there are no style tags", -> - expect(DraftStore._convertToInlineStyles).not.toHaveBeenCalled() - expect(DraftStore._onInlineStylesResult).not.toHaveBeenCalled() + expect(DraftStore._prepareBodyForQuoting).toHaveBeenCalled() describe "onComposeReply", -> describe "when the message provided as context has one or more 'ReplyTo' recipients", -> @@ -245,11 +243,7 @@ describe "DraftStore", -> expect(@model.replyToMessageId).toEqual(fakeMessage1.id) it "should sanitize the HTML", -> - expect(DraftStore._sanitizeBody).toHaveBeenCalled() - - it "should not call the style inliner when there are no style tags", -> - expect(DraftStore._convertToInlineStyles).not.toHaveBeenCalled() - expect(DraftStore._onInlineStylesResult).not.toHaveBeenCalled() + expect(DraftStore._prepareBodyForQuoting).toHaveBeenCalled() describe "onComposeReplyAll", -> describe "when the message provided as context has one or more 'ReplyTo' recipients", -> @@ -329,45 +323,7 @@ describe "DraftStore", -> expect(@model.replyToMessageId).toEqual(undefined) it "should sanitize the HTML", -> - expect(DraftStore._sanitizeBody).toHaveBeenCalled() - - it "should not call the style inliner when there are no style tags", -> - expect(DraftStore._convertToInlineStyles).not.toHaveBeenCalled() - expect(DraftStore._onInlineStylesResult).not.toHaveBeenCalled() - - describe "inlining #{body[i..-1]}" - - _onInlineStylesResult: (event, {body, clientId}) => - delete @_inlineStylePromises[clientId] - @_inlineStyleResolvers[clientId](body) - delete @_inlineStyleResolvers[clientId] - return - - _sanitizeBody: (body) -> - return sanitizeHtml body, - allowedTags: DOMUtils.permissiveTags() - allowedAttributes: DOMUtils.permissiveAttributes() - allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'data' ] + InlineStyleTransformer.run(body).then (body) => + SanitizeTransformer.run(body, SanitizeTransformer.Preset.UnsafeOnly) _onPopoutBlankDraft: => account = AccountStore.current() diff --git a/src/flux/stores/message-body-processor.coffee b/src/flux/stores/message-body-processor.coffee index 5f54628a6..5ba2c17a5 100644 --- a/src/flux/stores/message-body-processor.coffee +++ b/src/flux/stores/message-body-processor.coffee @@ -83,4 +83,5 @@ class MessageBodyProcessor @_recentlyProcessedA.unshift(item) @_recentlyProcessedD[key] = item + module.exports = new MessageBodyProcessor() diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 890c532df..4572c9d2b 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -151,10 +151,15 @@ class NylasExports # Services @load "UndoManager", 'undo-manager' @load "SoundRegistry", 'sound-registry' - @load "QuotedHTMLParser", 'services/quoted-html-parser' - @load "QuotedPlainTextParser", 'services/quoted-plain-text-parser' @load "NativeNotifications", 'native-notifications' + @load "QuotedHTMLTransformer", 'services/quoted-html-transformer' + @load "QuotedPlainTextTransformer", 'services/quoted-plain-text-transformer' + @load "SanitizeTransformer", 'services/sanitize-transformer' + @load "InlineStyleTransformer", 'services/inline-style-transformer' + @requireDeprecated "QuotedHTMLParser", 'services/quoted-html-transformer', + instead: 'QuotedHTMLTransformer' + # Errors @get "APIError", -> require('../flux/errors').APIError @get "OfflineError", -> require('../flux/errors').OfflineError diff --git a/src/services/inline-style-transformer.coffee b/src/services/inline-style-transformer.coffee new file mode 100644 index 000000000..9be9566d6 --- /dev/null +++ b/src/services/inline-style-transformer.coffee @@ -0,0 +1,46 @@ +{ipcRenderer} = require "electron" +RegExpUtils = require '../regexp-utils' +crypto = require 'crypto' +_ = require 'underscore' + +class InlineStyleTransformer + constructor: -> + @_inlineStylePromises = {} + @_inlineStyleResolvers = {} + ipcRenderer.on 'inline-styles-result', @_onInlineStylesResult + + run: (html) => + return Promise.resolve(html) unless html and _.isString(html) and html.length > 0 + return Promise.resolve(html) unless RegExpUtils.looseStyleTag().test(html) + + key = crypto.createHash('md5').update(html).digest('hex') + + # http://stackoverflow.com/questions/8695031/why-is-there-often-a-inside-the-style-tag + # https://regex101.com/r/bZ5tX4/1 + html = html.replace /]*>[\n\r]*[\n\r]*<\/style/g, (full, content) -> + "#{body[i..-1]}" + + _onInlineStylesResult: (event, {html, key}) => + delete @_inlineStylePromises[key] + @_inlineStyleResolvers[key](html) + delete @_inlineStyleResolvers[key] + return + +module.exports = new InlineStyleTransformer() diff --git a/src/services/quoted-html-parser.coffee b/src/services/quoted-html-transformer.coffee similarity index 99% rename from src/services/quoted-html-parser.coffee rename to src/services/quoted-html-transformer.coffee index eb5354fab..e144d397c 100644 --- a/src/services/quoted-html-parser.coffee +++ b/src/services/quoted-html-transformer.coffee @@ -2,7 +2,7 @@ _ = require 'underscore' crypto = require 'crypto' DOMUtils = require '../dom-utils' -class QuotedHTMLParser +class QuotedHTMLTransformer annotationClass: "nylas-quoted-text-segment" @@ -198,4 +198,4 @@ class QuotedHTMLParser return Array::slice.call(doc.querySelectorAll('blockquote')) -module.exports = new QuotedHTMLParser +module.exports = new QuotedHTMLTransformer diff --git a/src/services/quoted-plain-text-parser.coffee b/src/services/quoted-plain-text-transformer.coffee similarity index 99% rename from src/services/quoted-plain-text-parser.coffee rename to src/services/quoted-plain-text-transformer.coffee index 2d436954f..669e4f9d9 100644 --- a/src/services/quoted-plain-text-parser.coffee +++ b/src/services/quoted-plain-text-transformer.coffee @@ -6,7 +6,7 @@ _str = require 'underscore.string' # For plain text emails we look for lines that look like they're quoted # text based on common conventions: # -# For HTML emails use QuotedHTMLParser +# For HTML emails use QuotedHTMLTransformer # # This is modied from https://github.com/mko/emailreplyparser, which is a # JS port of GitHub's Ruby https://github.com/github/email_reply_parser diff --git a/src/services/sanitize-transformer.coffee b/src/services/sanitize-transformer.coffee new file mode 100644 index 000000000..4bac25a61 --- /dev/null +++ b/src/services/sanitize-transformer.coffee @@ -0,0 +1,42 @@ +sanitizeHtml = require 'sanitize-html' + +Preset = + Strict: + allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'br', 'img', 'ul', 'ol', 'li', 'strike', 'table'] + allowedAttributes: + a: ['href', 'name'] + img: ['src', 'alt'] + transformTags: + h1: "p" + h2: "p" + h3: "p" + h4: "p" + h5: "p" + h6: "p" + div: "p" + pre: "p" + blockquote: "p" + + Permissive: + allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'br', 'img', 'ul', 'ol', 'li', 'strike', 'table', 'tr', 'td', 'th', 'col', 'colgroup'] + allowedAttributes: [ 'abbr', 'accept', 'acceptcharset', 'accesskey', 'action', 'align', 'alt', 'async', 'autocomplete', 'axis', 'border', 'bgcolor', 'cellpadding', 'cellspacing', 'char', 'charoff', 'charset', 'checked', 'classid', 'classname', 'colspan', 'cols', 'content', 'contextmenu', 'controls', 'coords', 'data', 'datetime', 'defer', 'dir', 'disabled', 'download', 'draggable', 'enctype', 'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget', 'frame', 'frameborder', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'htmlfor', 'httpequiv', 'icon', 'id', 'label', 'lang', 'list', 'loop', 'low', 'manifest', 'marginheight', 'marginwidth', 'max', 'maxlength', 'media', 'mediagroup', 'method', 'min', 'multiple', 'muted', 'name', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'poster', 'preload', 'radiogroup', 'readonly', 'rel', 'required', 'role', 'rowspan', 'rows', 'rules', 'sandbox', 'scope', 'scoped', 'scrolling', 'seamless', 'selected', 'shape', 'size', 'sizes', 'sortable', 'sorted', 'span', 'spellcheck', 'src', 'srcdoc', 'srcset', 'start', 'step', 'style', 'summary', 'tabindex', 'target', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wmode' ] + + UnsafeOnly: + allowedTags: ["a", "abbr", "address", "area", "article", "aside", "audio", "b", "bdi", "bdo", "big", "blockquote", "body", "br", "button", "canvas", "caption", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt", "em", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "i", "img", "input", "ins", "kbd", "keygen", "label", "legend", "li", "main", "map", "mark", "menu", "menuitem", "meta", "meter", "nav", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "section", "select", "small", "source", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "u", "ul", "var", "video", "wbr"] + allowedAttributes: [ 'abbr', 'accept', 'acceptcharset', 'accesskey', 'action', 'align', 'alt', 'async', 'autocomplete', 'axis', 'border', 'bgcolor', 'cellpadding', 'cellspacing', 'char', 'charoff', 'charset', 'checked', 'classid', 'classname', 'colspan', 'cols', 'content', 'contextmenu', 'controls', 'coords', 'data', 'datetime', 'defer', 'dir', 'disabled', 'download', 'draggable', 'enctype', 'form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget', 'frame', 'frameborder', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'htmlfor', 'httpequiv', 'icon', 'id', 'label', 'lang', 'list', 'loop', 'low', 'manifest', 'marginheight', 'marginwidth', 'max', 'maxlength', 'media', 'mediagroup', 'method', 'min', 'multiple', 'muted', 'name', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'poster', 'preload', 'radiogroup', 'readonly', 'rel', 'required', 'role', 'rowspan', 'rows', 'rules', 'sandbox', 'scope', 'scoped', 'scrolling', 'seamless', 'selected', 'shape', 'size', 'sizes', 'sortable', 'sorted', 'span', 'spellcheck', 'src', 'srcdoc', 'srcset', 'start', 'step', 'style', 'summary', 'tabindex', 'target', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wmode' ] + allowedSchemes: [ 'http', 'https', 'ftp', 'mailto', 'data' ] + + +class SanitizeTransformer + Preset: Preset + + run: (body, settings = Preset.Strict) -> + if settings.allowedAttributes instanceof Array + attrMap = {} + for tag in settings.allowedTags + attrMap[tag] = settings.allowedAttributes + settings.allowedAttributes = attrMap + + return Promise.resolve(sanitizeHtml(body, settings)) + +module.exports = new SanitizeTransformer() diff --git a/src/window-event-handler.coffee b/src/window-event-handler.coffee index ea7b81565..b11906b8e 100644 --- a/src/window-event-handler.coffee +++ b/src/window-event-handler.coffee @@ -125,6 +125,7 @@ class WindowEventHandler bindCommandToAction('core:copy', 'copy') bindCommandToAction('core:cut', 'cut') bindCommandToAction('core:paste', 'paste') + bindCommandToAction('core:paste-and-match-style', 'pasteAndMatchStyle') bindCommandToAction('core:undo', 'undo') bindCommandToAction('core:redo', 'redo') bindCommandToAction('core:select-all', 'selectAll')