From d12d45d2479ba655f91715bc30d69e3bae90dd37 Mon Sep 17 00:00:00 2001 From: jjaffeux Date: Sat, 11 Apr 2020 20:26:37 +0200 Subject: [PATCH] UX: events are now managed through md markup --- .../discourse_post_event/events_controller.rb | 16 +-- app/models/discourse_post_event/event.rb | 47 ++++++- .../discourse-post-event-builder.js.es6 | 119 ++++++++++++++++-- .../modal/discourse-post-event-builder.hbs | 25 ++-- .../widgets/discourse-post-event-dates.js.es6 | 2 +- .../widgets/discourse-post-event.js.es6 | 2 +- .../initializers/add-event-ui-builder.js.es6 | 72 ++++++----- ... => discourse-post-event-decorator.js.es6} | 9 +- config/locales/server.en.yml | 10 +- lib/discourse_post_event/event_parser.rb | 33 +++++ lib/discourse_post_event/event_validator.rb | 55 ++++++++ plugin.rb | 51 +++++--- spec/acceptance/post_spec.rb | 99 +++++++++++++++ spec/lib/event_parser_spec.rb | 42 +++++++ 14 files changed, 473 insertions(+), 109 deletions(-) rename assets/javascripts/initializers/{discourse-event-decorator.js.es6 => discourse-post-event-decorator.js.es6} (93%) create mode 100644 lib/discourse_post_event/event_parser.rb create mode 100644 lib/discourse_post_event/event_validator.rb create mode 100644 spec/lib/event_parser_spec.rb diff --git a/app/controllers/discourse_post_event/events_controller.rb b/app/controllers/discourse_post_event/events_controller.rb index 6671e69c..fd6ef0c5 100644 --- a/app/controllers/discourse_post_event/events_controller.rb +++ b/app/controllers/discourse_post_event/events_controller.rb @@ -55,21 +55,7 @@ module DiscoursePostEvent event = Event.find(params[:id]) guardian.ensure_can_edit!(event.post) guardian.ensure_can_act_on_event!(event) - event.enforce_utc!(event_params) - - case event_params[:status].to_i - when Event.statuses[:private] - raw_invitees = Array(event_params[:raw_invitees]) - event.update!(event_params.merge(raw_invitees: raw_invitees)) - event.enforce_raw_invitees! - when Event.statuses[:public] - event.update!(event_params.merge(raw_invitees: [])) - when Event.statuses[:standalone] - event.update!(event_params.merge(raw_invitees: [])) - event.invitees.destroy_all - end - - event.publish_update! + event.update_with_params!(event_params) serializer = EventSerializer.new(event, scope: guardian) render_json_dump(serializer) end diff --git a/app/models/discourse_post_event/event.rb b/app/models/discourse_post_event/event.rb index 0d217530..eee307c9 100644 --- a/app/models/discourse_post_event/event.rb +++ b/app/models/discourse_post_event/event.rb @@ -42,8 +42,10 @@ module DiscoursePostEvent validates :starts_at, presence: true + MIN_NAME_LENGTH = 5 + MAX_NAME_LENGTH = 30 validates :name, - length: { in: 5..30 }, + length: { in: MIN_NAME_LENGTH..MAX_NAME_LENGTH }, unless: -> (event) { event.name.blank? } validate :raw_invitees_length @@ -148,11 +150,11 @@ module DiscoursePostEvent end def enforce_utc!(params) - if params['starts_at'].present? - params['starts_at'] = Time.parse(params['starts_at']).utc + if params[:starts_at].present? + params[:starts_at] = Time.parse(params[:starts_at]).utc end - if params['ends_at'].present? - params['ends_at'] = Time.parse(params['ends_at']).utc + if params[:ends_at].present? + params[:ends_at] = Time.parse(params[:ends_at]).utc end end @@ -171,5 +173,40 @@ module DiscoursePostEvent def is_expired? Time.now > (self.ends_at || self.starts_at || Time.now) end + + def self.update_from_raw(post) + events = DiscoursePostEvent::EventParser.extract_events(post.raw) + if events.present? + event_params = events.first + event = post.event || Event.new(id: post.id) + params = { + name: event_params[:name] || event.name, + starts_at: event_params[:start] || event.starts_at, + ends_at: event_params[:end] || event.ends_at, + status: event_params[:status].present? ? Event.statuses[event_params[:status].to_sym] : event.status, + raw_invitees: event_params[:allowedGroups] ? event_params[:allowedGroups].split(',') : nil + } + event.enforce_utc!(params) + event.update_with_params!(params) + elsif post.event + post.event.destroy + end + end + + def update_with_params!(params) + case params[:status].to_i + when Event.statuses[:private] + raw_invitees = Array(params[:raw_invitees]) + self.update!(params.merge(raw_invitees: raw_invitees)) + self.enforce_raw_invitees! + when Event.statuses[:public] + self.update!(params.merge(raw_invitees: [])) + when Event.statuses[:standalone] + self.update!(params.merge(raw_invitees: [])) + self.invitees.destroy_all + end + + self.publish_update! + end end end diff --git a/assets/javascripts/discourse/controllers/discourse-post-event-builder.js.es6 b/assets/javascripts/discourse/controllers/discourse-post-event-builder.js.es6 index 0cc6c17e..43643bf3 100644 --- a/assets/javascripts/discourse/controllers/discourse-post-event-builder.js.es6 +++ b/assets/javascripts/discourse/controllers/discourse-post-event-builder.js.es6 @@ -1,3 +1,4 @@ +import TextLib from "discourse/lib/text"; import Group from "discourse/models/group"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import Controller from "@ember/controller"; @@ -58,14 +59,32 @@ export default Controller.extend(ModalFunctionality, { @action destroyPostEvent() { bootbox.confirm( - I18n.t("event.ui_builder.confirm_delete"), + I18n.t("discourse_post_event.builder_modal.confirm_delete"), I18n.t("no_value"), I18n.t("yes_value"), confirmed => { if (confirmed) { - this.model.eventModel - .destroyRecord() - .then(() => this.send("closeModal")); + return this.store + .find("post", this.model.eventModel.id) + .then(post => { + const raw = post.raw; + const newRaw = this._removeRawEvent(raw); + + if (newRaw) { + const props = { + raw: newRaw, + edit_reason: I18n.t("discourse_post_event.destroy_event") + }; + + return TextLib.cookAsync(newRaw).then(cooked => { + props.cooked = cooked.string; + return post + .save(props) + .catch(e => this.flash(extractError(e), "error")) + .then(result => result && this.send("closeModal")); + }); + } + }); } } ); @@ -73,17 +92,93 @@ export default Controller.extend(ModalFunctionality, { @action createEvent() { - this.model.eventModel - .save() - .then(() => this.send("closeModal")) - .catch(e => this.flash(extractError(e), "error")); + if (!this.startsAt) { + this.send("closeModal"); + return; + } + + const eventParams = this._buildEventParams(); + const markdownParams = []; + Object.keys(eventParams).forEach(key => { + markdownParams.push(`${key}="${eventParams[key]}"`); + }); + + this.toolbarEvent.addText( + `[wrap=event ${markdownParams.join(" ")}]\n[/wrap]` + ); + this.send("closeModal"); }, @action updateEvent() { - this.model.eventModel - .save() - .then(() => this.send("closeModal")) - .catch(e => this.flash(extractError(e), "error")); + const eventParams = this._buildEventParams(); + return this.store.find("post", this.model.eventModel.id).then(post => { + const raw = post.raw; + const newRaw = this._replaceRawEvent(eventParams, raw); + + if (newRaw) { + const props = { + raw: newRaw, + edit_reason: I18n.t("discourse_post_event.update_event") + }; + + return TextLib.cookAsync(newRaw).then(cooked => { + props.cooked = cooked.string; + return post + .save(props) + .catch(e => this.flash(extractError(e), "error")) + .then(result => result && this.send("closeModal")); + }); + } + }); + }, + + _buildEventParams() { + const eventParams = { + start: this.startsAt, + status: this.model.eventModel.status, + name: this.model.eventModel.name + }; + + if (this.endsAt) { + eventParams.end = this.endsAt; + } + + if (this.model.eventModel.status === "private") { + eventParams.allowedGroups = ( + this.model.eventModel.raw_invitees || [] + ).join(","); + } + + return eventParams; + }, + + _removeRawEvent(raw) { + const eventRegex = new RegExp( + `\\[wrap=event\\s(.*?)\\]\\n\\[\\/wrap\\]`, + "m" + ); + + return raw.replace(eventRegex, ""); + }, + + _replaceRawEvent(eventparams, raw) { + const eventRegex = new RegExp(`\\[wrap=event\\s(.*?)\\]`, "m"); + const eventMatches = raw.match(eventRegex); + + if (eventMatches && eventMatches[1]) { + const markdownParams = []; + const eventParams = this._buildEventParams(); + Object.keys(eventParams).forEach(eventParam => + markdownParams.push(`${eventParam}="${eventParams[eventParam]}"`) + ); + + return raw.replace( + eventRegex, + `[wrap=event ${markdownParams.join(" ")}]` + ); + } + + return false; } }); diff --git a/assets/javascripts/discourse/templates/modal/discourse-post-event-builder.hbs b/assets/javascripts/discourse/templates/modal/discourse-post-event-builder.hbs index 76eb1896..2c34192d 100644 --- a/assets/javascripts/discourse/templates/modal/discourse-post-event-builder.hbs +++ b/assets/javascripts/discourse/templates/modal/discourse-post-event-builder.hbs @@ -19,19 +19,6 @@ }} {{#event-field label="discourse_post_event.builder_modal.status.label"}} - - + {{/event-field}} {{#event-field enabled=allowsInvitees label="discourse_post_event.builder_modal.invitees.label"}} diff --git a/assets/javascripts/discourse/widgets/discourse-post-event-dates.js.es6 b/assets/javascripts/discourse/widgets/discourse-post-event-dates.js.es6 index a42854f8..ad5ce636 100644 --- a/assets/javascripts/discourse/widgets/discourse-post-event-dates.js.es6 +++ b/assets/javascripts/discourse/widgets/discourse-post-event-dates.js.es6 @@ -25,7 +25,7 @@ export default createWidget("discourse-post-event-dates", { attrs.eventModel.status !== "standalone" ) { let participants; - const label = I18n.t("event.post_ui.participants", { + const label = I18n.t("discourse_post_event.event_ui.participants", { count: attrs.eventModel.stats.going }); if (attrs.eventModel.stats.going > 0) { diff --git a/assets/javascripts/discourse/widgets/discourse-post-event.js.es6 b/assets/javascripts/discourse/widgets/discourse-post-event.js.es6 index 1dc2fe16..35c8971c 100644 --- a/assets/javascripts/discourse/widgets/discourse-post-event.js.es6 +++ b/assets/javascripts/discourse/widgets/discourse-post-event.js.es6 @@ -136,7 +136,7 @@ export default createWidget("discourse-post-event", { {{#unless transformed.isStandaloneEvent}} {{#if state.eventModel.is_expired}} - {{i18n "event.expired"}} + {{i18n "discourse_post_event.models.event.expired"}} {{else}} diff --git a/assets/javascripts/initializers/add-event-ui-builder.js.es6 b/assets/javascripts/initializers/add-event-ui-builder.js.es6 index cf24d9c4..8c28c909 100644 --- a/assets/javascripts/initializers/add-event-ui-builder.js.es6 +++ b/assets/javascripts/initializers/add-event-ui-builder.js.es6 @@ -1,45 +1,43 @@ import { withPluginApi } from "discourse/lib/plugin-api"; import showModal from "discourse/lib/show-modal"; -import { Promise } from "rsvp"; function initializeEventBuilder(api) { - api.attachWidgetAction("post", "showEventBuilder", function({ - postId, - topicId - }) { - return new Promise(resolve => { - if (postId) { - this.store - .find("discourse-post-event-event", postId) - .then(resolve) - .catch(() => { - const eventModel = this.store.createRecord( - "discourse-post-event-event" - ); - eventModel.setProperties({ - id: postId, - status: "public" - }); - resolve(eventModel); - }); - } else if (this.model) { - resolve(this.model); - } - }).then(eventModel => { - showModal("discourse-post-event-builder", { - model: { eventModel, topicId }, - modalClass: "discourse-post-event-builder" - }); - }); - }); + const currentUser = api.getCurrentUser(); + const siteSettings = api.container.lookup("site-settings:main"); - api.decorateWidget("post-admin-menu:after", dec => { - return dec.attach("post-admin-menu-button", { - icon: "calendar-day", - label: "discourse_event.builder.attach", - action: "showEventBuilder", - actionParam: { postId: dec.attrs.id, topicId: dec.attrs.topicId } - }); + api.onToolbarCreate(toolbar => { + if (!currentUser.staff) { + return; + } + + const composer = toolbar.context.outletArgs.composer; + if ( + !composer.replyingToTopic && + (composer.topicFirstPost || + (composer.editingPost && + composer.post && + composer.post.post_number === 1)) + ) { + toolbar.addButton({ + title: "discourse_post_event.builder_modal.attach", + id: "insertEvent", + group: "insertions", + icon: "calendar-day", + perform: toolbarEvent => { + const eventModel = toolbar.context.store.createRecord( + "discourse-post-event-event" + ); + eventModel.setProperties({ + status: "public" + }); + + showModal("discourse-post-event-builder").setProperties({ + toolbarEvent, + model: { eventModel } + }); + } + }); + } }); } diff --git a/assets/javascripts/initializers/discourse-event-decorator.js.es6 b/assets/javascripts/initializers/discourse-post-event-decorator.js.es6 similarity index 93% rename from assets/javascripts/initializers/discourse-event-decorator.js.es6 rename to assets/javascripts/initializers/discourse-post-event-decorator.js.es6 index 7e5645a3..c3624cb7 100644 --- a/assets/javascripts/initializers/discourse-event-decorator.js.es6 +++ b/assets/javascripts/initializers/discourse-post-event-decorator.js.es6 @@ -18,8 +18,9 @@ function cleanUp() { function _attachWidget(api, cooked, eventModel) { const existing = cooked.querySelector(".discourse-post-event"); + const wrap = cooked.querySelector("[data-wrap=event]"); - if (eventModel) { + if (eventModel && wrap) { let widgetHeight = 300; if (eventModel.can_update_attendance) { @@ -31,7 +32,7 @@ function _attachWidget(api, cooked, eventModel) { eventContainer.classList.add("is-loading"); eventContainer.style.height = `${widgetHeight}px`; eventContainer.innerHTML = '
'; - cooked.prepend(eventContainer); + wrap.prepend(eventContainer); const dates = []; const startsAt = moment(eventModel.starts_at); @@ -79,7 +80,7 @@ function _attachWidget(api, cooked, eventModel) { } } -function initializeEventDecorator(api) { +function initializeDiscoursePostEventDecorator(api) { api.cleanupStream(cleanUp); api.decorateCooked(($cooked, helper) => { @@ -128,7 +129,7 @@ export default { initialize(container) { const siteSettings = container.lookup("site-settings:main"); if (siteSettings.discourse_post_event_enabled) { - withPluginApi("0.8.7", initializeEventDecorator); + withPluginApi("0.8.7", initializeDiscoursePostEventDecorator); } } }; diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 8daed8cb..648eea40 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -24,5 +24,11 @@ en: errors: models: event: - raw_invitees_length: "An event is limited to %{count} users/groups" - ends_at_before_starts_at: "An event can't end before it starts" + only_one_event: A post can only have one event. + must_be_in_first_post: An event can only be in the first post of a topic. + raw_invitees_length: "An event is limited to %{count} users/groups." + ends_at_before_starts_at: "An event can't end before it starts." + start_must_be_present_and_a_valid_date: "An event requires a valid start date." + end_must_be_a_valid_date: "End date must be a valid date." + name: + length: "Event name length must be between %{minimum} and %{maximum} characters." diff --git a/lib/discourse_post_event/event_parser.rb b/lib/discourse_post_event/event_parser.rb new file mode 100644 index 00000000..354cec7a --- /dev/null +++ b/lib/discourse_post_event/event_parser.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +EVENT_REGEX = /\[wrap=event\s(.*?)\](?:\n|\\n)?\[\/wrap\]/m + +VALID_OPTIONS = [ + :start, + :end, + :status, + :allowedGroups, + :name +] + +module DiscoursePostEvent + class EventParser + def self.extract_events(str) + str.scan(EVENT_REGEX).map do |scan| + extract_options(scan[0].gsub(/\\/, '')) + end.compact + end + + def self.extract_options(str) + options = nil + str.split(" ").each do |option| + key, value = option.split("=") + if VALID_OPTIONS.include?(key.to_sym) && value + options ||= {} + options[key.to_sym] = value.delete('\\"') + end + end + options + end + end +end diff --git a/lib/discourse_post_event/event_validator.rb b/lib/discourse_post_event/event_validator.rb new file mode 100644 index 00000000..192da945 --- /dev/null +++ b/lib/discourse_post_event/event_validator.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module DiscoursePostEvent + class EventValidator + def initialize(post) + @post = post + end + + def validate_event + extracted_events = DiscoursePostEvent::EventParser::extract_events(@post.raw) + + if extracted_events.count == 0 + return false + end + + if extracted_events.count > 1 + @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.only_one_event")) + return false + end + + if !@post.is_first_post? + @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.must_be_in_first_post")) + return false + end + + extracted_event = extracted_events.first + + if extracted_event[:start].blank? || (DateTime.parse(extracted_event[:start]) rescue nil).nil? + @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.start_must_be_present_and_a_valid_date")) + return false + end + + if extracted_event[:end].present? && (DateTime.parse(extracted_event[:end]) rescue nil).nil? + @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.end_must_be_a_valid_date")) + return false + end + + if extracted_event[:start].present? && extracted_event[:end].present? + if Time.parse(extracted_event[:start]) > Time.parse(extracted_event[:end]) + @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.ends_at_before_starts_at")) + return false + end + end + + if extracted_event[:name].present? && extracted_event[:name] + if !(Event::MIN_NAME_LENGTH..Event::MAX_NAME_LENGTH).cover?(extracted_event[:name].length) + @post.errors.add(:base, I18n.t('discourse_post_event.errors.models.event.name.length', minimum: Event::MIN_NAME_LENGTH, maximum: Event::MAX_NAME_LENGTH)) + return false + end + end + + true + end + end +end diff --git a/plugin.rb b/plugin.rb index 4c734504..8a94bb15 100644 --- a/plugin.rb +++ b/plugin.rb @@ -81,6 +81,8 @@ after_initialize do "../app/controllers/discourse_post_event/upcoming_events_controller.rb", "../app/models/discourse_post_event/event.rb", "../app/models/discourse_post_event/invitee.rb", + "../lib/discourse_post_event/event_parser.rb", + "../lib/discourse_post_event/event_validator.rb", "../lib/discourse_post_event/event_finder.rb", "../app/serializers/discourse_post_event/invitee_serializer.rb", "../app/serializers/discourse_post_event/event_serializer.rb" @@ -88,25 +90,6 @@ after_initialize do ::ActionController::Base.prepend_view_path File.expand_path("../app/views", __FILE__) - reloadable_patch do - require 'post' - - class ::Post - has_one :event, - dependent: :destroy, - class_name: 'DiscoursePostEvent::Event', - foreign_key: :id - end - end - - add_to_serializer(:post, :event) do - DiscoursePostEvent::EventSerializer.new(object.event, scope: scope, root: false) - end - - add_to_serializer(:post, :include_event?) do - SiteSetting.discourse_post_event_enabled - end - Discourse::Application.routes.append do mount ::DiscoursePostEvent::Engine, at: '/' end @@ -124,6 +107,36 @@ after_initialize do get '/upcoming-events' => 'upcoming_events#index' end + reloadable_patch do + require 'post' + + class ::Post + has_one :event, + dependent: :destroy, + class_name: 'DiscoursePostEvent::Event', + foreign_key: :id + + validate :valid_event + def valid_event + return unless self.raw_changed? + validator = DiscoursePostEvent::EventValidator.new(self) + validator.validate_event + end + end + end + + add_to_serializer(:post, :event) do + DiscoursePostEvent::EventSerializer.new(object.event, scope: scope, root: false) + end + + add_to_serializer(:post, :include_event?) do + SiteSetting.discourse_post_event_enabled + end + + DiscourseEvent.on(:post_process_cooked) do |doc, post| + DiscoursePostEvent::Event.update_from_raw(post) + end + DiscourseEvent.on(:post_destroyed) do |post| if SiteSetting.discourse_post_event_enabled && post.event post.event.update!(deleted_at: Time.now) diff --git a/spec/acceptance/post_spec.rb b/spec/acceptance/post_spec.rb index da7a1d61..0248c4e2 100644 --- a/spec/acceptance/post_spec.rb +++ b/spec/acceptance/post_spec.rb @@ -17,6 +17,105 @@ describe Post do SiteSetting.discourse_post_event_enabled = true end + context 'when a post is updated' do + context 'when the post has a valid event' do + context 'context the event markup is removed' do + it 'destroys the associated event' do + start = Time.now.utc.iso8601(3) + + post = PostCreator.create!( + user, + title: 'Sell a boat party', + raw: "[wrap=event start=#{start}]\n[/wrap]", + ) + + expect(post.event.persisted?).to eq(true) + + revisor = PostRevisor.new(post, post.topic) + revisor.revise!(user, raw: 'The event is over. Come back another day.') + + expect(post.reload.event).to be(nil) + end + end + end + end + + context 'when a post is created' do + context 'when the post contains one valid event' do + it 'creates the post event' do + start = Time.now.utc.iso8601(3) + + post = PostCreator.create!( + user, + title: 'Sell a boat party', + raw: "[wrap=event start=#{start}]\n[/wrap]", + ) + + expect(post.persisted?).to eq(true) + expect(post.event.persisted?).to eq(true) + expect(post.event.starts_at).to eq_time(Time.parse(start)) + end + end + + context 'when the post contains one invalid event' do + context 'when start is not provided or is invalid' do + it 'raises an error' do + expect { + PostCreator.create!( + user, + title: 'Sell a boat party', + raw: "[wrap=event end=\"1\"]\n[/wrap]", + ) + }.to( + raise_error(ActiveRecord::RecordNotSaved) + .with_message(I18n.t("discourse_post_event.errors.models.event.start_must_be_present_and_a_valid_date")) + ) + + expect { + PostCreator.create!( + user, + title: 'Sell a boat party', + raw: "[wrap=event start=\"x\"]\n[/wrap]", + ) + }.to( + raise_error(ActiveRecord::RecordNotSaved) + .with_message(I18n.t("discourse_post_event.errors.models.event.start_must_be_present_and_a_valid_date")) + ) + end + end + + context 'when end is provided and is invalid' do + it 'raises an error' do + expect { + PostCreator.create!( + user, + title: 'Sell a boat party', + raw: "[wrap=event start=\"#{Time.now.utc.iso8601(3)}\" end=\"d\"]\n[/wrap]", + ) + }.to( + raise_error(ActiveRecord::RecordNotSaved) + .with_message(I18n.t("discourse_post_event.errors.models.event.end_must_be_a_valid_date")) + ) + end + end + end + + context 'when the post contains multiple events' do + it 'raises an error' do + expect { + PostCreator.create!( + user, + title: 'Sell a boat party', + raw: "[wrap=event start=\"#{Time.now.utc.iso8601(3)}\"]\n[/wrap] foo [wrap=event start=\"#{Time.now.utc.iso8601(3)}\"]\n[/wrap]", + ) + }.to( + raise_error(ActiveRecord::RecordNotSaved) + .with_message(I18n.t("discourse_post_event.errors.models.event.only_one_event")) + ) + end + end + end + context 'when a post with an event is destroyed' do it 'sets deleted_at on the post_event' do expect(post_event.deleted_at).to be_nil diff --git a/spec/lib/event_parser_spec.rb b/spec/lib/event_parser_spec.rb new file mode 100644 index 00000000..e7368a8e --- /dev/null +++ b/spec/lib/event_parser_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DiscoursePostEvent::EventParser do + subject { DiscoursePostEvent::EventParser } + + it 'works with no event' do + events = subject.extract_events('this could be a nice event') + expect(events.length).to eq(0) + end + + it 'finds one event' do + events = subject.extract_events('[wrap=event start="foo" end="bar"]\n[/wrap]') + expect(events.length).to eq(1) + end + + it 'finds multiple events' do + events = subject.extract_events('[wrap=event start="foo" end="bar"]\n[/wrap] baz [wrap=event start="foo" end="bar"]\n[/wrap]') + expect(events.length).to eq(2) + end + + it 'parses options' do + events = subject.extract_events('[wrap=event start="foo" end="bar"]\n[/wrap]') + expect(events[0][:start]).to eq("foo") + expect(events[0][:end]).to eq("bar") + end + + it 'works with escaped string' do + events = subject.extract_events("I am going to get that fixed.\n\n[wrap=event start=\"bar\"]\n[/wrap]\n\n[wrap=event start=\"foo\"]\n[/wrap]") + expect(events[0][:start]).to eq("bar") + expect(events[1][:start]).to eq("foo") + end + + it 'doesn’t parse invalid options' do + events = subject.extract_events("I am going to get that fixed.\n\n[wrap=event start=\"foo\" something=\"bar\"]\n[/wrap]") + expect(events[0][:something]).to be(nil) + + events = subject.extract_events("I am going to get that fixed.\n\n[wrap=event something=\"bar\"]\n[/wrap]") + expect(events).to eq([]) + end +end