DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Natural Language Parser for Recurring Events using Stimulus

This article was originally published on Rails Designer


In this article I explored how I add recurring events in Rails. Let's now look at how to add a natural language parser input field. Instead of having users select from dropdown menus with Daily, Weekly, etc., I want users to type things like every week or monthly on the 15th.

Something like this:

Image description

(not pretty, but it works! 😄)

This feature is built on top of the work from the previous article. View the complete implementation in this commit.

First, let's look at the view changes. Replace the previous basic select input, in app/views/events/_form.html.erb, with a text field:

- <div>
-   <%= form.label :recurring_type, "Repeats" %>
-   <%= form.select :recurring_type, [
-     ["Daily", "daily"],
-     ["Weekly", "weekly"],
-     ["Every 2 weeks", "biweekly"],
-     ["Monthly", "monthly"]
-   ], { include_blank: "No recurrence" } %>
+ <div data-controller="recurring">
+   <%= form.label :natural_recurring %>
+   <%= form.text_field :natural_recurring, data: {action: "recurring#parse"} %>
+   <%= form.hidden_field :recurring_rule, data: {recurring_target: "input"} %>
+
+   <small data-recurring-target="feedback"></small>
</div>
Enter fullscreen mode Exit fullscreen mode

Now the Stimulus controller that handles the user input and provides feedback:

// app/javascript/controllers/recurring_controller.js
import { Controller } from "@hotwired/stimulus"
import { RecurringParser } from "src/recurring_parser"

export default class extends Controller {
  static targets = ["input", "feedback"]

  parse(event) {
    const result = this.#parser.parse(event.currentTarget.value)

    if (result.valid) {
      this.feedbackTarget.textContent = "✓"

      this.inputTarget.value = JSON.stringify(result.rule)
    } else {
      this.feedbackTarget.textContent = "Don't know what you mean…"

      this.inputTarget.value = ""
    }
  }

  get #parser() {
    return new RecurringParser({ backend: "iceCube" })
  }
}
Enter fullscreen mode Exit fullscreen mode

Really a basic Stimulus controller that is possible because the core of the implementation lives in the RecurringParser class. It uses a backend pattern that currently only supports IceCube (which I also used in the previous article), but is designed to be extensible for other recurring event implementations:

// app/javascript/src/recurring_parser.js
import { iceCube } from "src/recurring_parser/backends/ice_cube"
import { patterns } from "src/recurring_parser/patterns"

export class RecurringParser {
  static backends = { iceCube }

  constructor(options = {}) {
    this.backend = RecurringParser.backends[options.backend || "iceCube"]
  }

  parse(input) {
    if (!input?.trim()) return { valid: false }

    let result = { valid: false }

    Object.values(patterns).forEach(pattern => {
      const matches = input.match(pattern.regex)

      if (!matches) return

      result = {
        valid: true,
        rule: pattern.parse(matches, this.backend)
      }
    })

    return result
  }
}
Enter fullscreen mode Exit fullscreen mode

See how the result object returns valid: false by default? And true if a match is found? These valid and rule keys are used in the above Stimulus controller to show the feedback message.

Feel like this is all a bit overwhelming? Check out the book JavaScript for Rails Developers. 💡

The parser matches input against different patterns using regular expressions. Each pattern knows how to convert itself into the correct backend format:

// app/javascript/src/recurring_parser/patterns.js
export const patterns = {
  daily: {
    regex: /^every\s+day|daily$/i,
    parse: (_, backend) => backend.daily()
  },

  weekly: {
    regex: new RegExp(`^(?:every\\s+week|weekly)(?:\\s+on\\s+${dayPattern})?$`, "i"),
    parse: (matches, backend) => {
      const currentDay = new Date().getDay()
      const day = matches[1] ? dayToNumber(matches[1]) : currentDay

      return backend.weekly(day)
    }
  },

  // … see patterns for all rules https://github.com/rails-designer-repos/recurring-events/commit/89580605f472c6408ad1c0ce4eb91876c0a1068a
}
Enter fullscreen mode Exit fullscreen mode

The patterns support variations like:

  • every day or daily
  • weekly on monday or just weekly
  • every 2 weeks or bi-weekly
  • monthly on the 15th
  • yearly on december 25

The backend adapter (in this case for IceCube) defines how these patterns translate to the actual recurring event implementation need for the Event's recurring_rules field:

// app/javascript/src/recurring_parser/backends.js
export const iceCube = {
  daily: () => ({
    rule_type: "IceCube::DailyRule",
    validations: {},
    interval: 1
  }),

  weekly: (day) => ({
    rule_type: "IceCube::WeeklyRule",
    validations: { day: [day] },
    interval: 1
  }),

  // … see repo for all rules https://github.com/rails-designer-repos/recurring-events/commit/89580605f472c6408ad1c0ce4eb91876c0a1068a
}
Enter fullscreen mode Exit fullscreen mode

This backend pattern makes it easy to add support for other recurring event implementations (like recurrence). You'd simply need to create a new backend that implements the same interface but generates the appropriate rule structure for your system.

Finally remove the unnecessary attributes from the permitted params (you can also remove the include Recurrence::Builder from app/models/event.rb).

# app/controllers/events_controller.rb
def event_params
-  params.expect(event: [:title, :starts_at, :ends_at, :recurring_type, :recurring_day, :recurring_interval, :recurring_until])
+  params.expect(event: [:title, :starts_at, :ends_at, :recurring_rule, :recurring_until])
end
Enter fullscreen mode Exit fullscreen mode

The recurring_rule parameter now contains a JSON structure that maps directly to IceCube's rule system, making it easy to create the actual recurring events on the backend.

And with that you have the basics for natural language parsing. As you can see the app/javascript/src/recurring_parser/patterns.js is mainly a big regex—which will only get bigger when adding support for more ways to add recurring options (and that is without support for other languages than English even!).

Top comments (0)