DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

2 1

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!).

Warp.dev image

Warp is the #1 coding agent.

Warp outperforms every other coding agent on the market, and gives you full control over which model you use. Get started now for free, or upgrade and unlock 2.5x AI credits on Warp's paid plans.

Download Warp

Top comments (0)

👋 Kindness is contagious

Dive into this insightful article, celebrated by the caring DEV Community. Programmers from all walks of life are invited to share and expand our collective wisdom.

A simple thank-you can make someone’s day—drop your kudos in the comments!

On DEV, spreading knowledge paves the way and strengthens our community ties. If this piece helped you, a brief note of appreciation to the author truly counts.

Let’s Go!