概要
Ruby on Rails 6.1を使用して、インスタ風のWebサイトにハッシュタグ機能を実装してみました。
まだまだ初心者なので、説明が詳しくできていない部分が多々あると思いますがご了承ください。
間違っている部分に関しては、ご指摘いただければ加筆修正させていただきます。
今回はRouteの編集やハッシュタグ別Viewの記述以外を実装しています。
後半は下記のリンクをご覧ください。
https://qiita.com/Prog_taro/items/7185153e4b878ddb1ce5
参考にしたサイト
RailsでインスタやTwitterのようなハッシュタグ検索を実装(gemなし)
https://qiita.com/Naoki1126/items/4ea584a4c149d3ad5472
Railsで作ったインスタもどきのキャプションにハッシュタグを実装
https://qiita.com/goppy001/items/791c946abdb41c9495bb
中身はいろいろと変更していますが、ベースは上記のサイトを参考にしました。
どちらもハッシュタグのリンクを作成する際にhtml_safeを使用されていますが僕の場合は、XSS対策もしっかりしたい!という意図があったので作り替えました。
今回の記事はこの部分がメインのような感じになっています。
実装したいこと
- インスタグラムなどでよくあるハッシュタグ(#Qiita のようなもの)を実装したい。
- ユーザーが投稿する際にキャプション部分にハッシュタグを入れるとハッシュタグとして認識し、ハッシュタグごとのリンクを作成してくれるようにしたい。
今回はハッシュタグを入力する欄を別で設けるわけではなく、キャプション部分にそのまま書くことを前提とします。
テーブルを作成
$ rails g model Hashtag label:string
マイグレーションファイルを編集
class CreateHashtags < ActiveRecord::Migration[6.1]
def change
create_table :hashtags do |t|
t.string :label, null: false, comment: "タグの名前"
t.timestamps
end
add_index :hashtags, :label, unique: true, comment: "タグに一意制約を設定"
end
end
コメントがついていますが、自分が見てわかりやすくするために書いただけなので特に気にしないでください。
t.string :label, null: false
空のタグを保存されないように設定しています。
add_index :hashtags, :label, unique: true, comment: "タグに一意制約を設定"
ハッシュタグに対して一意制約を設定しています。
中間テーブルを作成
$ rails g migration CreatePostTaggings post:references hashtag:references
マイグレーションファイルを編集
class CreatePostTaggings < ActiveRecord::Migration[6.1]
def change
create_table :post_taggings do |t|
t.references :post, foreign_key: { on_delete: :cascade }, comment: "Postテーブルとの関連付け"
t.references :hashtag, foreign_key: true, comment: "Hashtagテーブルとの関連付け"
t.timestamps
end
add_index :post_taggings, [:post_id, :hashtag_id], unique: true, comment: "postに対して同じtagを複数つけられないよう設定"
end
end
これで必要なモデルが準備できました!
マイグレーション
$ rails db:migrate
Hashtagモデルに追加
validates :label, presence: true, length: { maximum: 50 }
LEAD_POUND = "[##]"
HASHTAG_CONDITIONS = %r{#{LEAD_POUND}[\w\p{Han}ぁ-ヶヲ-゚ー]+}
def self.hashtag_scan(caption)
caption.scan(HASHTAG_CONDITIONS)
end
def self.pound_delete_at_hashtag(word)
word.gsub(/#{LEAD_POUND}/, '')
end
Hashtagモデルに今回実装したいハッシュタグの定義と、後ほど使用するメソッドを定義しています。
実際に実装した順序は少し違いますが、ここでまとめさせていただきます。
LEAD_POUND = "[##]"
これはハッシュタグの条件として先頭に半角または全角の"#"があること、という意味で書いています。
後ほど何回か使用する上、変更することがないものなので定数としてコードをまとめました。
HASHTAG_CONDITIONS = %r{#{LEAD_POUND}[\w\p{Han}ぁ-ヶヲ-゚ー]+}
同様にハッシュタグの条件を書いています。
こちらも度々使用するコードなので定数としてまとめています。
self.pound_delete_at_hashtag(word)
ハッシュタグを保存する時とリンクを作成する際に使用するので、まとめてメソッドにしています。
先頭の"#"を外して保存し、リンク先にも同様に"#"を外して表示させるために使っています。
Postモデルに追加
has_many :post_taggings, dependent: :destroy
has_many :hashtags, through: :post_taggings
after_create :generate_hashtag
・
・ 省略
・
private
def generate_hashtag
post_labels = Hashtag.hashtag_scan(caption)
post_labels.uniq.each do |word|
self.hashtags << Hashtag.find_or_create_by(label: Hashtag.pound_delete_at_hashtag(word))
end
end
上記のコードを少しだけ説明させていただきます。
def generate_hashtag
post_labels = Hashtag.hashtag_scan(caption)
post_labels.uniq.each do |word|
self.hashtags << Hashtag.find_or_create_by(label: Hashtag.pound_delete_at_hashtag(word))
end
end
この部分は、ハッシュタグを保存する働きをするコードです。
post_labels = Hashtag.hashtag_scan(caption)
ここで先ほどHashtagモデルに定義したhashtag_scanを使用して、キャプションからハッシュタグの部分を抜き出しています。
そしてpost_labelsに入ったハッシュタグをeachで回して、該当のハッシュタグが存在しなかった場合に新しいハッシュタグとして保存しています。
中間テーブルに追加
class PostTagging < ApplicationRecord
belongs_to :post
belongs_to :hashtag
validates :post_id, presence: true
validates :hashtag_id, presence: true
validates :post_id, uniqueness: { scope: :hashtag_id }
end
Postsヘルパーに追加
module PostsHelper
def caption_and_hashtags_in_array(caption)
hashtags = Hashtag.hashtag_scan(caption)
if hashtags.blank?
return [caption]
end
dup_hash = {}
hashtags.uniq.each do |word|
dup_hash[word] = 0
end
hash_point = hashtags.map do |num|
top_point = caption.index(num, dup_hash[num])
bottom_point = caption.index(num, dup_hash[num]) + num.length - 1
dup_hash[num] = bottom_point
[top_point, bottom_point]
end
cap_arr = [caption[0...hash_point[0][0]]]
hash_point.each_with_index do |arr, i|
tag = caption[arr[0]..arr[1]]
usually_cap = caption[(hash_point[i-1][1] + 1)...hash_point[(i)][0]]
cap_arr.push(usually_cap, tag)
end
cap_arr.push(caption[(hash_point.last[1] + 1)..-1])
cap_arr.map do |word|
if word.match(Hashtag::HASHTAG_CONDITIONS)
delete_pound_word = Hashtag.pound_delete_at_hashtag(word)
link_to word, "/post/hashtag/" + delete_pound_word
else
word
end
end
end
end
上から順に説明させていただきます。
hashtags = Hashtag.hashtag_scan(caption)
hashtagsにキャプションからハッシュタグの条件に合うものを入れています。
if hashtags.blank?
return [caption]
end
もちろんハッシュタグがないcaptionも存在するので、その場合はそのままキャプションを配列で返すコードにしています。
returnを使う事によってハッシュタグが存在しない場合は明示的に終了するようにしています。
なぜ配列で返す必要があるのかというと、後ほどビュー側で実装する際に配列で出力しないとハッシュタグがリンクとして出力されなかったからです。
もしかしたら解決策があるかもしれないのですが、僕が実装する際にはできませんでした。
dup_hash = {}
hashtags.uniq.each do |word|
dup_hash[word] = 0
end
hash_point = hashtags.map do |num|
top_point = caption.index(num, dup_hash[num])
bottom_point = caption.index(num, dup_hash[num]) + num.length - 1
dup_hash[num] = bottom_point
[top_point, bottom_point]
end
今回はキャプションをインデックス番号で分割するやり方にしました。
ハッシュタグの先頭のインデックス番号、終わりのインデックス番号を取得してキャプションをその番号で分割し、リンクにするやり方です。
重複するタグが出てきても問題なく判別させるために、まずはhashtagsをユニークにした状態で各ハッシュタグのインデックス検索開始位置を0にしています。
そして重複するタグが出てきた場合にこの番号を更新していくという作業です。
最終的なインデックス番号をhash_pointに入れています。
インデックスの使い方については下記を参照してください。
https://docs.ruby-lang.org/ja/latest/method/String/i/index.html
cap_arr = [caption[0...hash_point[0][0]]]
hash_point.each_with_index do |arr, i|
tag = caption[arr[0]..arr[1]]
usually_cap = caption[(hash_point[i-1][1] + 1)...hash_point[(i)][0]]
cap_arr.push(usually_cap, tag)
end
cap_arr.push(caption[(hash_point.last[1] + 1)..-1])
この部分でキャプションをhash_pointを使って分割しています。
cap_arrの初期値として、キャプションの1文字目から最初のハッシュタグの1文字前までを入れています。
cap_arrに順序よく配列として入れてしまえばユーザーが入力したものをそのまま出力できると考えました。
まず、cap_arrには1番目のハッシュタグまでのキャプションが入っているはずなので、ハッシュタグを取得します。tagにはハッシュタグが入っています。
usually_capには1個目のtagと2個目のtagの間の部分が入っています。スペースなどもここに入ります。
そして最初のtagと次のタグまでの部分が取得できたのでcap_arrに追加しています。
この流れをタグごとにeach_with_indexで繰り返しているということです。
each_with_indexについては下記を参照してください。
https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/each_with_index.html
cap_arr.map do |word|
if word.match(Hashtag::HASHTAG_CONDITIONS)
delete_pound_word = Hashtag.pound_delete_at_hashtag(word)
link_to word, "/post/hashtag/" + delete_pound_word
else
word
end
end
この部分はハッシュタグの部分をリンクに、それ以外はそのままにする働きをしています。
ハッシュタグの先頭の"#"を削除してリンクにしています。
Viewを編集
<% caption_and_hashtags_in_array(post.caption).each do |word| %>
<%= word %>
<% end %>
投稿が表示される部分に上記のコードを使用します。
上記のようにすることで、一度配列にしたキャプションをビューに表示させることができます。
最後に...
今回はRouteを作成して実際に該当ハッシュタグのページを作成する手前までを書かせていただきました。
かなり時間をかけて作った部分なので大変でしたが、これでハッシュタグをリンクにすることができます。
Routeの作成や、Viewでの表示に関しては後半で書いています。
後半は下記リンクをご覧ください。
https://qiita.com/Prog_taro/items/7185153e4b878ddb1ce5
ここまで読んでいただき本当にありがとうございました。