Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails:繰り返しイベントの日付や期間を自然言語で指定できるパーサーをStimulusで実装する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails:繰り返しイベントの日付や期間を自然言語で指定できるパーサーをStimulusで実装する(翻訳)

Rails: カレンダーの「繰り返しイベント」を実装しよう(翻訳)

前回の記事↑では、繰り返しイベントを追加する方法について解説いたしました。今回は、入力フィールドを解析する自然言語パーサーを追加する方法を見ていきましょう。
つまり、ユーザーが「Daily(毎日)」とか「Weekly(毎週)」といった項目をドロップダウンから選ぶ代わりに、「every week()毎週」とか「毎月15日に(monthly on the 15th)」のように言葉で入力できるようにするというものです。

以下の動画のような感じです。

(ぶかっこうですが、ちゃんと動いてます)😄

この記事は前回記事を元にしています。完全な実装については以下をどうぞ。

参考: Recurring natural input · rails-designer-repos/recurring-events@8958060

最初にビューの変更点を見てみましょう。app/views/events/_form.html.erbにある元の基本的なセレクタ入力を、以下のテキストフィールドに置き換えます。

- <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>

次は、ユーザー入力を処理して結果を反映するためのStimulusコントローラについて説明します。

// 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" })
  }
}

実装のコアはRecurringParserクラスの方にあるので、非常に基本的なStimulusのコントローラを実現できています。バックエンドパターンでサポートしているのは、前回記事でも使ったIceCubeだけですが、実装をさまざまな繰り返しイベント用に拡張できるように設計してあります。

// 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
  }
}

上のresultオブジェクトがデフォルトでvalid: falseをどのように返すか、マッチするものが見つかったときにどうやってtrueを返すかおわかりでしょうか?
これらのvalidキーとruleキーは、結果メッセージを表示するために上のStimulusコントローラで使われています。

お知らせ

この記事がちょっと自分には難しいかもと思えたら、以下の書籍をぜひどうぞ!

参考: JavaScript for Rails Developers Book (by Rails Designer)

このパーサーは、正規表現を用いてユーザー入力をさまざまなパターンと照合しています。個別のパターンは、自分自身を正しいバックエンド形式に変換する方法を認識しています。

// 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)
    }
  },

  // すべてのルール用のパターンはこちら: https://github.com/rails-designer-repos/recurring-events/commit/89580605f472c6408ad1c0ce4eb91876c0a1068a
}

このパターンでは、以下のような入力のバリエーションをサポートしています。

  • 毎日(every dayやdaily)
  • 毎週土曜(weekly on monday)や毎週(weekly)
  • 2週間おき(every 2 weeks)や隔週(bi-weekly)
  • 毎月15日(monthly on the 15t)
  • 毎年12月25日(yearly on december 25)

バックエンドアダプタ(この場合はIcdeCube用)では、これらのパターンを、イベントのrecurring_rulesフィールドで必要になる実際のイベント実装に変換する方法を以下のように定義しています。

// 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
  }),

  // すべてのルールはこちら:  https://github.com/rails-designer-repos/recurring-events/commit/89580605f472c6408ad1c0ce4eb91876c0a1068a
}

このバックエンドパターンを用いることで、IceCube以外の繰り返しイベント実装(例: recurrence)のサポートを追加しやすくなります。つまり、これと同じインターフェースを実装して、システムに適したルール構造を生成する新しいバックエンドを作成するだけで済むわけです。

fnando/recurrence - GitHub

最後に、strong parameterで許可済みのパラメータから、不要な属性を削除します(app/models/event.rbのinclude Recurrence::Builderもついでに削除可能です)

# 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

これで、IceCubeのルールシステムに直接対応づけられるJSON構造がrecurring_ruleパラメータに含まれるようになり、繰り返しイベントを実際にバックエンド上で楽に作成できるようになりました。

これで、自然言語解析の基盤が整いました。
app/javascript/src/recurring_parser/patterns.jsは巨大な正規表現パターンが中心となっていることがわかります。繰り返しイベントのオプションをサポートするパターンを追加すれば、正規表現はさらに膨れ上がります(英語以外の言語をサポートしていなくてもそうなります)。

関連記事

Rails: カレンダーの「繰り返しイベント」を実装しよう(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。