diff options
author | Silvio Zimmer <silvio.zimmer@die-kinderwelt.com> | 2021-04-21 13:45:47 +0300 |
---|---|---|
committer | Silvio Zimmer <silvio.zimmer@die-kinderwelt.com> | 2021-04-21 13:45:47 +0300 |
commit | 3e8edcf91acd7ccbff6e4fa8fd81d1f67a3955a3 (patch) | |
tree | 3a62db28114044c5869c45aeaae6cd71414b2eab | |
parent | 513b5e38b539e0f16f4cdbe27d91be2d52c60290 (diff) |
add option to place signature above quoted text
Signed-off-by: Silvio Zimmer <silvio.zimmer@die-kinderwelt.com>
-rw-r--r-- | lib/Controller/AccountsController.php | 10 | ||||
-rw-r--r-- | lib/Db/MailAccount.php | 9 | ||||
-rw-r--r-- | lib/Migration/Version1100Date20210421113423.php | 57 | ||||
-rw-r--r-- | src/ReplyBuilder.js | 18 | ||||
-rw-r--r-- | src/ckeditor/quote/QuotePlugin.js | 53 | ||||
-rw-r--r-- | src/ckeditor/signature/InsertSignatureCommand.js | 56 | ||||
-rw-r--r-- | src/components/Composer.vue | 6 | ||||
-rw-r--r-- | src/components/SignatureSettings.vue | 32 | ||||
-rw-r--r-- | src/components/TextEditor.vue | 10 | ||||
-rw-r--r-- | src/store/index.js | 1 | ||||
-rw-r--r-- | src/tests/unit/ReplyBuilder.spec.js | 10 | ||||
-rw-r--r-- | src/util/text.js | 1 | ||||
-rw-r--r-- | tests/Integration/Db/MailAccountTest.php | 2 |
13 files changed, 233 insertions, 32 deletions
diff --git a/lib/Controller/AccountsController.php b/lib/Controller/AccountsController.php index 607f543ab..ed90346a1 100644 --- a/lib/Controller/AccountsController.php +++ b/lib/Controller/AccountsController.php @@ -234,6 +234,10 @@ class AccountsController extends Controller { * @param string|null $editorMode * @param int|null $order * @param bool|null $showSubscribedOnly + * @param int|null $draftsMailboxId + * @param int|null $sentMailboxId + * @param int|null $trashMailboxId + * @param bool|null $signatureAboveQuote * * @return JSONResponse * @@ -245,7 +249,8 @@ class AccountsController extends Controller { bool $showSubscribedOnly = null, int $draftsMailboxId = null, int $sentMailboxId = null, - int $trashMailboxId = null): JSONResponse { + int $trashMailboxId = null, + bool $signatureAboveQuote = null): JSONResponse { $account = $this->accountService->find($this->currentUserId, $id); $dbAccount = $account->getMailAccount(); @@ -267,6 +272,9 @@ class AccountsController extends Controller { if ($trashMailboxId !== null) { $dbAccount->setTrashMailboxId($trashMailboxId); } + if ($signatureAboveQuote !== null) { + $dbAccount->setSignatureAboveQuote($signatureAboveQuote); + } return new JSONResponse( $this->accountService->save($dbAccount)->toJson() ); diff --git a/lib/Db/MailAccount.php b/lib/Db/MailAccount.php index 1fa65bb2d..a41b0528c 100644 --- a/lib/Db/MailAccount.php +++ b/lib/Db/MailAccount.php @@ -89,6 +89,8 @@ use OCP\AppFramework\Db\Entity; * @method void setSieveUser(?string $sieveUser) * @method string|null getSievePassword() * @method void setSievePassword(?string $sievePassword) + * @method bool|null isSignatureAboveQuote() + * @method void setSignatureAboveQuote(bool $signatureAboveQuote) */ class MailAccount extends Entity { protected $userId; @@ -133,6 +135,8 @@ class MailAccount extends Entity { protected $sieveUser; /** @var string|null */ protected $sievePassword; + /** @var bool */ + protected $signatureAboveQuote = false; /** * @param array $params @@ -182,6 +186,9 @@ class MailAccount extends Entity { if (isset($params['showSubscribedOnly'])) { $this->setShowSubscribedOnly($params['showSubscribedOnly']); } + if (isset($params['signatureAboveQuote'])) { + $this->setSignatureAboveQuote($params['signatureAboveQuote']); + } $this->addType('inboundPort', 'integer'); $this->addType('outboundPort', 'integer'); @@ -195,6 +202,7 @@ class MailAccount extends Entity { $this->addType('trashMailboxId', 'integer'); $this->addType('sieveEnabled', 'boolean'); $this->addType('sievePort', 'integer'); + $this->addType('signatureAboveQuote', 'boolean'); } /** @@ -220,6 +228,7 @@ class MailAccount extends Entity { 'sentMailboxId' => $this->getSentMailboxId(), 'trashMailboxId' => $this->getTrashMailboxId(), 'sieveEnabled' => ($this->isSieveEnabled() === true), + 'signatureAboveQuote' => ($this->isSignatureAboveQuote() === true), ]; if (!is_null($this->getOutboundHost())) { diff --git a/lib/Migration/Version1100Date20210421113423.php b/lib/Migration/Version1100Date20210421113423.php new file mode 100644 index 000000000..54ee74feb --- /dev/null +++ b/lib/Migration/Version1100Date20210421113423.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Silvio Zimmer <silvio.zimmer@die-kinderwelt.com> + * + * @author 2021 Silvio Zimmer <silvio.zimmer@die-kinderwelt.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1100Date20210421113423 extends SimpleMigrationStep +{ + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * + * @return ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) + { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $accountsTable = $schema->getTable('mail_accounts'); + + $accountsTable->addColumn('signature_above_quote', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + + return $schema; + } +} diff --git a/src/ReplyBuilder.js b/src/ReplyBuilder.js index 26199b897..3efba5522 100644 --- a/src/ReplyBuilder.js +++ b/src/ReplyBuilder.js @@ -36,29 +36,31 @@ export const buildReplyBody = (original, from, date, replyOnTop = true) => { const startEnd = '<p></p><p></p>' const plainBody = '<br>> ' + original.value.replace(/\n/g, '<br>> ') const htmlBody = `<blockquote>${original.value}</blockquote>` + const quoteStart = '<div class="quote">' + const quoteEnd = '</div>' switch (original.format) { case 'plain': if (from) { const dateString = moment.unix(date).format('LLL') return replyOnTop - ? html(`${startEnd}"${from.label}" ${from.email} – ${dateString}` + plainBody) - : html(`"${from.label}" ${from.email} – ${dateString}` + plainBody + startEnd) + ? html(`${startEnd}${quoteStart}"${from.label}" ${from.email} – ${dateString}` + plainBody + quoteEnd) + : html(`${quoteStart}"${from.label}" ${from.email} – ${dateString}` + plainBody + quoteEnd + startEnd) } else { return replyOnTop - ? html(`${startEnd}${plainBody}`) - : html(`${plainBody}${startEnd}`) + ? html(`${startEnd}${quoteStart}${plainBody}${quoteEnd}`) + : html(`${quoteStart}${plainBody}${quoteEnd}${startEnd}`) } case 'html': if (from) { const dateString = moment.unix(date).format('LLL') return replyOnTop - ? html(`${startEnd}"${from.label}" ${from.email} – ${dateString}<br>${htmlBody}`) - : html(`"${from.label}" ${from.email} – ${dateString}<br>${htmlBody}${startEnd}`) + ? html(`${startEnd}${quoteStart}"${from.label}" ${from.email} – ${dateString}<br>${htmlBody}${quoteEnd}`) + : html(`${quoteStart}"${from.label}" ${from.email} – ${dateString}<br>${htmlBody}${quoteEnd}${startEnd}`) } else { return replyOnTop - ? html(`${startEnd}${htmlBody}`) - : html(`${htmlBody}${startEnd}`) + ? html(`${startEnd}${quoteStart}${htmlBody}${quoteEnd}`) + : html(`${quoteStart}${htmlBody}${quoteEnd}${startEnd}`) } } diff --git a/src/ckeditor/quote/QuotePlugin.js b/src/ckeditor/quote/QuotePlugin.js new file mode 100644 index 000000000..a937037e5 --- /dev/null +++ b/src/ckeditor/quote/QuotePlugin.js @@ -0,0 +1,53 @@ +/** + * @copyright 2021 Silvio Zimmer <silvio.zimmer@die-kinderwelt.com> + * + * @author 2021 Silvio Zimmer <silvio.zimmer@die-kinderwelt.com> + * + * @license GNU AGPL version 3 or any later version + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin' + +export default class Quote extends Plugin { + + init() { + this._defineSchema() + this._defineConverters() + } + + _defineSchema() { + const schema = this.editor.model.schema + + schema.register('quote', { + allowIn: '$root', + isLimit: true, + allowContentOf: '$block', + }) + } + + _defineConverters() { + const conversion = this.editor.conversion + + conversion.elementToElement({ + model: 'quote', + view: { + name: 'div', + classes: 'quote', + }, + }) + } + +} diff --git a/src/ckeditor/signature/InsertSignatureCommand.js b/src/ckeditor/signature/InsertSignatureCommand.js index f9c8855e8..d3a82d7a3 100644 --- a/src/ckeditor/signature/InsertSignatureCommand.js +++ b/src/ckeditor/signature/InsertSignatureCommand.js @@ -35,14 +35,21 @@ export default class InsertSignatureCommand extends Command { } } - insertSignatureElement(editor, writer, value) { + /** + * + * @param {*} editor the editor instance + * @param {*} writer the writer instance + * @param {string} signature the signature text + * @param {boolean} signatureAboveQuote the signature position: above/below the quoted text + */ + insertSignatureElement(editor, writer, signature, signatureAboveQuote) { // Skip empty signature - if (value.length === 0) { + if (signature.length === 0) { return } // Convert an HTML string to a view document fragment: - const viewFragment = editor.data.processor.toView(value) + const viewFragment = editor.data.processor.toView(signature) // Convert the view document fragment to a model document fragment // in the context of $root. This conversion takes the schema into @@ -54,24 +61,47 @@ export default class InsertSignatureCommand extends Command { // which has a loosened schema. const modelFragment = editor.data.toModel(viewFragment) - const signature = writer.createElement('signature') - writer.append(writer.createText('--'), signature) - writer.append(writer.createElement('paragraph'), signature) - writer.append(modelFragment, signature) + const signatureElement = writer.createElement('signature') + writer.append(writer.createText('-- '), signatureElement) + writer.append(writer.createElement('paragraph'), signatureElement) + writer.append(modelFragment, signatureElement) + if (signatureAboveQuote) { + writer.append(writer.createElement('paragraph'), signatureElement) + } + + const signaturePosition = signatureAboveQuote ? this.findPositionAboveQuote(editor, writer) : writer.createPositionAt(editor.model.document.getRoot(), 'end') + editor.model.insertContent(signatureElement, signaturePosition) + } - editor.model.insertContent( - signature, - writer.createPositionAt(editor.model.document.getRoot(), 'end') + /** + * + * @param {*} editor the editor instance + * @param {*} writer the writer instance + * @returns {*} the position above the quoted text; position 1 if no quote found + */ + findPositionAboveQuote(editor, writer) { + // Create a range spanning over the entire root content: + const range = editor.model.createRangeIn( + editor.model.document.getRoot() ) + + // Iterate over all items in this range: + for (const value of range.getWalker({ shallow: true })) { + if (value.item.is('element') && value.item.name === 'quote') { + return writer.createPositionBefore(value.item) + } + } + + return writer.createPositionAt(editor.model.document.getRoot(), 'end') } /** - * @param {string} value signature to append + * @param {*} param0 the signature text and position */ - execute({ value }) { + execute({ signature, signatureAboveQuote }) { this.editor.model.change(writer => { this.removeSignatureElement(this.editor, writer) - this.insertSignatureElement(this.editor, writer, value) + this.insertSignatureElement(this.editor, writer, signature, signatureAboveQuote) }) } diff --git a/src/components/Composer.vue b/src/components/Composer.vue index f3293df5b..dcf1b41cd 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -404,6 +404,7 @@ export default { signature: account.signature, name: account.name, emailAddress: account.emailAddress, + signatureAboveQuote: account.signatureAboveQuote, }, account.aliases.map((alias) => { return { @@ -414,6 +415,7 @@ export default { signature: alias.signature, name: alias.name, emailAddress: alias.alias, + signatureAboveQuote: account.signatureAboveQuote, } }), ]) @@ -668,8 +670,8 @@ export default { onInputChanged() { this.saveDraftDebounced(this.getMessageData) if (this.appendSignature) { - const signature = this.selectedAlias?.signature || '' - this.bus.$emit('insertSignature', toHtml(detect(signature)).value) + const signatureValue = toHtml(detect(this.selectedAlias.signature)).value + this.bus.$emit('insertSignature', signatureValue, this.selectedAlias.signatureAboveQuote) this.appendSignature = false } }, diff --git a/src/components/SignatureSettings.vue b/src/components/SignatureSettings.vue index 48d2c6bad..8a90f903d 100644 --- a/src/components/SignatureSettings.vue +++ b/src/components/SignatureSettings.vue @@ -21,6 +21,16 @@ <template> <div class="section"> + <div> + <input + id="signature-above-quote-toggle" + v-model="signatureAboveQuote" + type="checkbox" + class="checkbox"> + <label for="signature-above-quote-toggle"> + {{ t("mail", "Place signature above quoted text") }} + </label> + </div> <Multiselect v-if="identities.length > 1" :allow-empty="false" @@ -72,6 +82,7 @@ export default { bus: new Vue(), identity: null, signature: '', + signatureAboveQuote: this.account.signatureAboveQuote, } }, computed: { @@ -93,6 +104,27 @@ export default { return identities }, }, + watch: { + async signatureAboveQuote(val, oldVal) { + await this.$store + .dispatch('patchAccount', { + account: this.account, + data: { + signatureAboveQuote: val, + }, + }) + .then(() => { + logger.debug('signature above quoted updated to ' + val) + }) + .catch((error) => { + logger.error('could not update signature above quote', { + error, + }) + this.signatureAboveQuote = oldVal + throw error + }) + }, + }, beforeMount() { this.changeIdentity(this.identities[0]) }, diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index 72e458d7f..a8bd8a8f2 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -42,6 +42,7 @@ import LinkPlugin from '@ckeditor/ckeditor5-link/src/link' import ListStyle from '@ckeditor/ckeditor5-list/src/liststyle' import ParagraphPlugin from '@ckeditor/ckeditor5-paragraph/src/paragraph' import SignaturePlugin from '../ckeditor/signature/SignaturePlugin' +import QuotePlugin from '../ckeditor/quote/QuotePlugin' import { getLanguage } from '@nextcloud/l10n' import DOMPurify from 'dompurify' @@ -76,7 +77,7 @@ export default { }, }, data() { - const plugins = [EssentialsPlugin, ParagraphPlugin, SignaturePlugin] + const plugins = [EssentialsPlugin, ParagraphPlugin, SignaturePlugin, QuotePlugin] const toolbar = ['undo', 'redo'] if (this.html) { @@ -202,8 +203,11 @@ export default { const modelFragment = this.editorInstance.data.toModel(viewFragment) this.editorInstance.model.insertContent(modelFragment) }, - onInsertSignature(signature) { - this.editorInstance.execute('insertSignature', { value: signature }) + onInsertSignature(signatureParam, signatureAboveQuoteParam) { + this.editorInstance.execute('insertSignature', { + signature: signatureParam, + signatureAboveQuote: signatureAboveQuoteParam, + }) }, }, } diff --git a/src/store/index.js b/src/store/index.js index 344a40abe..6f8e57343 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -47,6 +47,7 @@ export default new Vuex.Store({ emailAddress: '', name: '', showSubscribedOnly: false, + signatureAboveQuote: false, }, }, accountList: [UNIFIED_ACCOUNT_ID], diff --git a/src/tests/unit/ReplyBuilder.spec.js b/src/tests/unit/ReplyBuilder.spec.js index 3e9930143..4344b8c99 100644 --- a/src/tests/unit/ReplyBuilder.spec.js +++ b/src/tests/unit/ReplyBuilder.spec.js @@ -23,9 +23,9 @@ import { buildRecipients, buildReplyBody, - buildReplySubject + buildReplySubject, } from '../../ReplyBuilder' -import {html, plain} from '../../util/text' +import { html, plain } from '../../util/text' describe('ReplyBuilder', () => { it('creates a reply body without any sender', () => { @@ -34,8 +34,8 @@ describe('ReplyBuilder', () => { const replyBodyTop = buildReplyBody(body) const replyBodyBottom = buildReplyBody(body, undefined, undefined, false) - expect(replyBodyTop).to.deep.equal(html('<p></p><p></p><br>> Newsletter<br>> hello<br>> cheers')) - expect(replyBodyBottom).to.deep.equal(html('<br>> Newsletter<br>> hello<br>> cheers<p></p><p></p>')) + expect(replyBodyTop).to.deep.equal(html('<p></p><p></p><div class="quote"><br>> Newsletter<br>> hello<br>> cheers</div>')) + expect(replyBodyBottom).to.deep.equal(html('<div class="quote"><br>> Newsletter<br>> hello<br>> cheers</div><p></p><p></p>')) }) it('creates a reply body', () => { @@ -59,7 +59,7 @@ describe('ReplyBuilder', () => { false ) - expect(replyBodyTop.value.startsWith(html('<p></p><p></p>"Test User" test@user.ru – November 5, 2018 ').value)).to.be.true + expect(replyBodyTop.value.startsWith(html('<p></p><p></p><div class="quote">"Test User" test@user.ru – November 5, 2018 ').value)).to.be.true expect(replyBodyBottom.value.endsWith(html('<p></p><p></p>').value)).to.be.true }) diff --git a/src/util/text.js b/src/util/text.js index 1ce1d6733..4b9804e62 100644 --- a/src/util/text.js +++ b/src/util/text.js @@ -132,6 +132,7 @@ export const toPlain = (text) => { .replace(/\n\n\n/g, '\n\n') // remove triple line breaks .replace(/^[\n\r]+/g, '') // trim line breaks at beginning and end .replace(/ $/gm, '') // trim white space at end of each line + .replace(/^--$/gm, '-- ') // hack to create the correct email signature separator ) } diff --git a/tests/Integration/Db/MailAccountTest.php b/tests/Integration/Db/MailAccountTest.php index fd2ad11e3..8db2729f8 100644 --- a/tests/Integration/Db/MailAccountTest.php +++ b/tests/Integration/Db/MailAccountTest.php @@ -70,6 +70,7 @@ class MailAccountTest extends TestCase { 'sentMailboxId' => null, 'trashMailboxId' => null, 'sieveEnabled' => false, + 'signatureAboveQuote' => false, ], $a->toJson()); } @@ -97,6 +98,7 @@ class MailAccountTest extends TestCase { 'sentMailboxId' => null, 'trashMailboxId' => null, 'sieveEnabled' => false, + 'signatureAboveQuote' => false, ]; $a = new MailAccount($expected); // TODO: fix inconsistency |