From 54a518b21d7f31064f638a068e344e3c0f37682e Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 21 Jun 2022 10:07:21 +1000 Subject: [PATCH] FIX: Quoting local dates bbcode regeneration (#17141) This commit allows quoting of discourse-local-date elements and converts the quoted tags back into bbcode so that the rendered quote will also render the discourse-local-date HTML. This works on single dates as well as date ranges, and supports all of the options used by discourse-local-date. This also necessitated adding addTextDecorateCallback to the to-markdown core lib (similar to addBlockDecorateCallback and addTagDecorateCallback) to transform the text nodes between date ranges to remove the -> in the final quote. c.f. https://meta.discourse.org/t/quotes-that-contain-date-time/101999 --- .../discourse/app/lib/to-markdown.js | 57 ++++++- .../discourse/tests/helpers/qunit-helpers.js | 2 + docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md | 2 +- .../discourse-local-dates-create-form.js | 39 +---- .../initializers/discourse-local-dates.js | 77 ++++++++++ .../lib/local-date-markup-generator.js | 58 ++++++++ .../acceptance/local-dates-quoting-test.js | 140 ++++++++++++++++++ 7 files changed, 331 insertions(+), 44 deletions(-) create mode 100644 plugins/discourse-local-dates/assets/javascripts/lib/local-date-markup-generator.js create mode 100644 plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js diff --git a/app/assets/javascripts/discourse/app/lib/to-markdown.js b/app/assets/javascripts/discourse/app/lib/to-markdown.js index c8bbbf7eb55..e6309b9d3e3 100644 --- a/app/assets/javascripts/discourse/app/lib/to-markdown.js +++ b/app/assets/javascripts/discourse/app/lib/to-markdown.js @@ -6,9 +6,11 @@ const MSO_LIST_CLASSES = [ let tagDecorateCallbacks = []; let blockDecorateCallbacks = []; +let textDecorateCallbacks = []; /** - * Allows to add support for custom inline markdown/bbcode + * Allows to add support for custom inline markdown/bbcode prefixes + * to convert nodes back to bbcode. * * ``` * addTagDecorateCallback(function (text) { @@ -29,7 +31,8 @@ export function clearTagDecorateCallbacks() { } /** - * Allows to add support for custom block markdown/bbcode + * Allows to add support for custom block markdown/bbcode prefixes + * to convert nodes back to bbcode. * * ``` * addBlockDecorateCallback(function (text) { @@ -48,6 +51,30 @@ export function clearBlockDecorateCallbacks() { blockDecorateCallbacks = []; } +/** + * Allows to add support for custom text node transformations + * based on the next/previous elements. + * + * ``` + * addTextDecorateCallback(function (text, nextElement, previousElement) { + * if ( + * startRangeOpts && + * nextElement?.attributes.class?.includes("discourse-local-date") && + * text === "→" + * ) { + * return ""; + * } + * }); + * ``` + */ +export function addTextDecorateCallback(callback) { + textDecorateCallbacks.push(callback); +} + +export function clearTextDecorateCallbacks() { + textDecorateCallbacks = []; +} + export class Tag { static named(name) { const klass = class NamedTag extends Tag {}; @@ -657,9 +684,10 @@ function tagByName(name) { } class Element { - constructor(element, parent, previous, next) { + constructor(element, parent, previous, next, metadata) { this.name = element.name; this.data = element.data; + this.metadata = metadata; this.children = element.children; this.attributes = element.attributes; @@ -682,6 +710,7 @@ class Element { tag() { const tag = new (tagByName(this.name) || Tag)(); tag.element = this; + tag.metadata = this.metadata; return tag; } @@ -709,6 +738,19 @@ class Element { } text = text.replace(/[\s\t]+/g, " "); + textDecorateCallbacks.forEach((callback) => { + const result = callback.call( + this, + text, + this.next, + this.previous, + this.metadata + ); + + if (typeof result !== "undefined") { + text = result; + } + }); return text; } @@ -721,8 +763,8 @@ class Element { return this.parentNames.filter((p) => names.includes(p)); } - static toMarkdown(element, parent, prev, next) { - return new Element(element, parent, prev, next).toMarkdown(); + static toMarkdown(element, parent, prev, next, metadata) { + return new Element(element, parent, prev, next, metadata).toMarkdown(); } static parseChildren(parent) { @@ -732,12 +774,15 @@ class Element { static parse(elements, parent = null) { if (elements) { let result = []; + let metadata = {}; for (let i = 0; i < elements.length; i++) { const prev = i === 0 ? null : elements[i - 1]; const next = i === elements.length ? null : elements[i + 1]; - result.push(Element.toMarkdown(elements[i], parent, prev, next)); + result.push( + Element.toMarkdown(elements[i], parent, prev, next, metadata) + ); } return result.join(""); diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 91077116d71..908a01511a5 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -67,6 +67,7 @@ import { resetDefaultSectionLinks as resetTopicsSectionLinks } from "discourse/l import { clearBlockDecorateCallbacks, clearTagDecorateCallbacks, + clearTextDecorateCallbacks, } from "discourse/lib/to-markdown"; const LEGACY_ENV = !setupApplicationTest; @@ -194,6 +195,7 @@ function testCleanup(container, app) { resetTopicsSectionLinks(); clearTagDecorateCallbacks(); clearBlockDecorateCallbacks(); + clearTextDecorateCallbacks(); } export function discourseModule(name, options) { diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md index 11647fc4c42..76c23fb12d9 100644 --- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md +++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Adds `beforeToMarkdownTagDecorate` and `beforeToMarkdownBlockDecorate` that allow to modify to-markdown behavior. +- N/A - Mistakenly bumped. ## [1.2.0] - 2022-03-18 diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js b/plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js index de87dabde11..2c7b9ed5608 100644 --- a/plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js +++ b/plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js @@ -14,6 +14,7 @@ import { propertyNotEqual } from "discourse/lib/computed"; import { schedule } from "@ember/runloop"; import { getOwner } from "discourse-common/lib/get-owner"; import { applyLocalDates } from "discourse/lib/local-dates"; +import generateDateMarkup from "discourse/plugins/discourse-local-dates/lib/local-date-markup-generator"; export default Component.extend({ timeFormat: "HH:mm:ss", @@ -262,43 +263,7 @@ export default Component.extend({ }, _generateDateMarkup(fromDateTime, options, isRange, toDateTime) { - let text = ``; - - if (isRange) { - let from = [fromDateTime.date, fromDateTime.time] - .filter((element) => !isEmpty(element)) - .join("T"); - let to = [toDateTime.date, toDateTime.time] - .filter((element) => !isEmpty(element)) - .join("T"); - text += `[date-range from=${from} to=${to}`; - } else { - text += `[date=${fromDateTime.date}`; - } - - if (fromDateTime.time && !isRange) { - text += ` time=${fromDateTime.time}`; - } - - if (fromDateTime.format && fromDateTime.format.length) { - text += ` format="${fromDateTime.format}"`; - } - - if (options.timezone) { - text += ` timezone="${options.timezone}"`; - } - - if (options.timezones && options.timezones.length) { - text += ` timezones="${options.timezones.join("|")}"`; - } - - if (options.recurring && !isRange) { - text += ` recurring="${options.recurring}"`; - } - - text += `]`; - - return text; + return generateDateMarkup(fromDateTime, options, isRange, toDateTime); }, @computed("advancedMode") diff --git a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js index c481b896108..003cddcdb39 100644 --- a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js +++ b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js @@ -7,6 +7,11 @@ import { downloadCalendar } from "discourse/lib/download-calendar"; import { renderIcon } from "discourse-common/lib/icon-library"; import I18n from "I18n"; import { hidePopover, showPopover } from "discourse/lib/d-popover"; +import { + addTagDecorateCallback, + addTextDecorateCallback, +} from "discourse/lib/to-markdown"; +import generateDateMarkup from "discourse/plugins/discourse-local-dates/lib/local-date-markup-generator"; // Import applyLocalDates from discourse/lib/local-dates instead export function applyLocalDates(dates, siteSettings) { @@ -66,6 +71,24 @@ function buildOptionsFromElement(element, siteSettings) { return opts; } +function buildOptionsFromMarkdownTag(element) { + const opts = {}; + + // siteSettings defaults as used by buildOptionsFromElement are purposefully + // ommitted to reproduce exactly what was on the original element + opts.time = element.attributes["data-time"]; + opts.date = element.attributes["data-date"]; + opts.recurring = element.attributes["data-recurring"]; + opts.timezones = element.attributes["data-timezones"]; + opts.timezone = element.attributes["data-timezone"]; + opts.calendar = (element.attributes["data-calendar"] || "on") === "on"; + opts.displayedTimezone = element.attributes["data-displayed-timezone"]; + opts.format = element.attributes["data-format"]; + opts.countdown = element.attributes["data-countdown"]; + + return opts; +} + function _rangeElements(element) { if (!element.parentElement) { return []; @@ -128,6 +151,60 @@ function initializeDiscourseLocalDates(api) { }, }, }); + + addTextDecorateCallback(function ( + text, + nextElement, + _previousElement, + metadata + ) { + if ( + metadata.discourseLocalDateStartRangeOpts && + nextElement?.attributes.class?.includes("discourse-local-date") && + text === "→" + ) { + return ""; + } + }); + addTagDecorateCallback(function () { + if (this.element.attributes.class?.includes("discourse-local-date")) { + if (this.metadata.discourseLocalDateStartRangeOpts) { + const startRangeOpts = this.metadata.discourseLocalDateStartRangeOpts; + const endRangeOpts = buildOptionsFromMarkdownTag(this.element); + const markup = generateDateMarkup( + { + date: startRangeOpts.date, + time: startRangeOpts.time, + format: startRangeOpts.format, + }, + endRangeOpts, + true, + { + date: endRangeOpts.date, + time: endRangeOpts.time, + format: endRangeOpts.format, + } + ); + this.prefix = markup; + this.metadata.discourseLocalDateStartRangeOpts = null; + return ""; + } + if (this.element.attributes["data-range"] === "true") { + this.metadata.discourseLocalDateStartRangeOpts = buildOptionsFromMarkdownTag( + this.element + ); + return ""; + } + const opts = buildOptionsFromMarkdownTag(this.element, siteSettings); + const markup = generateDateMarkup( + { date: opts.date, time: opts.time, format: opts.format }, + opts, + false + ); + this.prefix = markup; + return ""; + } + }); } function buildHtmlPreview(element, siteSettings) { diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/local-date-markup-generator.js b/plugins/discourse-local-dates/assets/javascripts/lib/local-date-markup-generator.js new file mode 100644 index 00000000000..33b0aa96df7 --- /dev/null +++ b/plugins/discourse-local-dates/assets/javascripts/lib/local-date-markup-generator.js @@ -0,0 +1,58 @@ +import { isEmpty } from "@ember/utils"; + +export default function generateDateMarkup( + fromDateTime, + options, + isRange, + toDateTime +) { + let text = ``; + + if (isRange) { + let from = [fromDateTime.date, fromDateTime.time] + .filter((element) => !isEmpty(element)) + .join("T"); + let to = [toDateTime.date, toDateTime.time] + .filter((element) => !isEmpty(element)) + .join("T"); + text += `[date-range from=${from} to=${to}`; + } else { + text += `[date=${fromDateTime.date}`; + } + + if (fromDateTime.time && !isRange) { + text += ` time=${fromDateTime.time}`; + } + + if (fromDateTime.format && fromDateTime.format.length) { + text += ` format="${fromDateTime.format}"`; + } + + if (options.timezone) { + text += ` timezone="${options.timezone}"`; + } + + if (options.countdown) { + text += ` countdown="${options.countdown}"`; + } + + if (options.displayedTimezone) { + text += ` displayedTimezone="${options.displayedTimezone}"`; + } + + if (options.timezones && options.timezones.length) { + if (Array.isArray(options.timezones)) { + text += ` timezones="${options.timezones.join("|")}"`; + } else { + text += ` timezones="${options.timezones}"`; + } + } + + if (options.recurring && !isRange) { + text += ` recurring="${options.recurring}"`; + } + + text += `]`; + + return text; +} diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js new file mode 100644 index 00000000000..c56b81864d0 --- /dev/null +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-quoting-test.js @@ -0,0 +1,140 @@ +import { cloneJSON } from "discourse-common/lib/object"; +import topicFixtures from "discourse/tests/fixtures/topic"; +import { + acceptance, + queryAll, + selectText, +} from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { click, visit } from "@ember/test-helpers"; + +acceptance("Local Dates - quoting", function (needs) { + needs.user(); + needs.settings({ discourse_local_dates_enabled: true }); + + needs.pretender((server, helper) => { + const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); + const firstPost = topicResponse.post_stream.posts[0]; + firstPost.cooked += `
This is a test + + + + June 17, 2022 8:00 AM (Perth) +
`; + + server.get("/t/280.json", () => helper.response(topicResponse)); + server.get("/t/280/:post_number.json", () => { + helper.response(topicResponse); + }); + }); + + test("quoting single local dates with basic options", async function (assert) { + await visit("/t/internationalization-localization/280"); + await selectText("#post_1 .select-local-date-test"); + await click(".insert-quote"); + assert.strictEqual( + queryAll(".d-editor-input").val().trim(), + `[quote=\"Uwe Keim, post:1, topic:280, username:uwe_keim\"] +This is a test [date=2022-06-17 time=10:00:00 timezone="Australia/Brisbane" displayedTimezone="Australia/Perth"] +[/quote]`, + "converts the date to markdown with all options correctly" + ); + }); +}); + +acceptance("Local Dates - quoting range", function (needs) { + needs.user(); + needs.settings({ discourse_local_dates_enabled: true }); + + needs.pretender((server, helper) => { + const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); + const firstPost = topicResponse.post_stream.posts[0]; + firstPost.cooked += `

Some text + + + + June 17, 2022 + + + + + June 18, 2022 +

`; + + server.get("/t/280.json", () => helper.response(topicResponse)); + server.get("/t/280/:post_number.json", () => { + helper.response(topicResponse); + }); + }); + + test("quoting a range of local dates", async function (assert) { + await visit("/t/internationalization-localization/280"); + await selectText("#post_1 .select-local-date-test"); + await click(".insert-quote"); + assert.strictEqual( + queryAll(".d-editor-input").val().trim(), + `[quote=\"Uwe Keim, post:1, topic:280, username:uwe_keim\"] +Some text [date-range from=2022-06-17T09:30:00 to=2022-06-18T10:30:00 format="LL" timezone="Australia/Brisbane" timezones="Africa/Accra|Australia/Brisbane|Europe/Paris"] +[/quote]`, + "converts the date range to markdown with all options correctly" + ); + }); +}); + +acceptance("Local Dates - quoting with recurring and countdown", function (needs) { + needs.user(); + needs.settings({ discourse_local_dates_enabled: true }); + + needs.pretender((server, helper) => { + const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); + const firstPost = topicResponse.post_stream.posts[0]; + firstPost.cooked += `

Testing countdown + + + + 21 hours +

+

Testing recurring + + + + Wednesday +

`; + + server.get("/t/280.json", () => helper.response(topicResponse)); + server.get("/t/280/:post_number.json", () => { + helper.response(topicResponse); + }); + }); + + test("quoting single local dates with recurring and countdown options", async function (assert) { + await visit("/t/internationalization-localization/280"); + await selectText("#post_1 .select-local-date-test"); + await click(".insert-quote"); + assert.strictEqual( + queryAll(".d-editor-input").val().trim(), + `[quote=\"Uwe Keim, post:1, topic:280, username:uwe_keim\"] +Testing countdown [date=2022-06-21 time=09:30:00 format="LL" timezone="Australia/Brisbane" countdown="true"] + +Testing recurring [date=2022-06-22 timezone="Australia/Brisbane" recurring="2.weeks"] +[/quote]`, + "converts the dates to markdown with all options correctly" + ); + }); +}); +