Skip to content

Commit a32ba3d

Browse files
authored
Add post processing step for creating Roll record (#8)
* Minimum working roll on post * Add unit tests * Add schema info
1 parent 51e50c8 commit a32ba3d

6 files changed

Lines changed: 223 additions & 4 deletions

File tree

app/model/rollmaster/roll.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
module ::Rollmaster
4+
class Roll < ActiveRecord::Base
5+
self.table_name = "rollmaster_rolls"
6+
7+
# let Post to Roll association occur via the cooked text.
8+
# Roll to Post association will be explicit for backtracking (i.e. auditing).
9+
belongs_to :post
10+
validates :raw, presence: true
11+
validates :notation, presence: true
12+
validates :result, presence: true
13+
end
14+
end
15+
16+
# == Schema Information
17+
#
18+
# Table name: rollmaster_rolls
19+
#
20+
# id :bigint not null, primary key
21+
# post_id :integer
22+
# raw :string
23+
# notation :string
24+
# result :string
25+
# created_at :datetime not null
26+
# updated_at :datetime not null
27+
#
28+
# Indexes
29+
# index_rollmaster_rolls_on_post_id (post_id)

db/.gitkeep

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
class CreateRollmasterRolls < ActiveRecord::Migration[7.2]
4+
def change
5+
create_table :rollmaster_rolls do |t|
6+
t.integer :post_id
7+
t.string :raw
8+
t.string :notation
9+
t.string :result
10+
11+
t.timestamps
12+
end
13+
add_index :rollmaster_rolls, :post_id
14+
end
15+
end
Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,116 @@
11
# frozen_string_literal: true
22

33
module ::Rollmaster
4+
SELECTOR_QUERY = ".bb-rollmaster[data-notation]"
5+
46
class HandleCookedPostProcess
57
def self.process(doc, post)
68
# Add your processing logic here
79

8-
# parse the post content
10+
# { raw, dom }[]
11+
roll_elements = []
12+
13+
# parse the post content for rolls and flatten
14+
doc
15+
.css(SELECTOR_QUERY)
16+
.each do |roll_element|
17+
original_notation = roll_element.attribute("data-notation").value
18+
19+
next if original_notation.blank?
20+
21+
original_notation
22+
.split(/\n/)
23+
.each do |notation|
24+
if notation.strip.empty?
25+
roll_elements << { raw: "", dom: roll_element } # let us keep empty lines
26+
else
27+
roll_elements << { raw: notation, dom: roll_element }
28+
end
29+
end
30+
roll_element.content = "" # clear the original notation
31+
end
32+
33+
return if roll_elements.empty?
34+
35+
# { raw, dom, formatted, result, error }[]
36+
roll_elements.each do |element|
37+
notation = element[:raw]
38+
next if notation.blank?
39+
40+
element.merge!(process_roll(notation))
41+
end
42+
43+
# { raw, dom, formatted, result, error, id }[]
44+
match_rolls(roll_elements, post) if post.id?
45+
save_rolls(roll_elements, post)
946

10-
# check for existing dice rolls associated with post
47+
roll_elements
48+
.group_by { |e| e[:dom] }
49+
.each do |dom, rolls|
50+
content =
51+
rolls.map do |e|
52+
if e[:error]
53+
e[:raw]
54+
elsif e[:raw].empty?
55+
""
56+
else
57+
e[:raw] + ": " + e[:result] # TODO: consider decorating with spans
58+
end
59+
end
60+
dom.content = CGI.unescapeHTML(content.join("\n"))
61+
dom["data-roll-id"] = rolls.map { |e| e[:id] }.join(",") if rolls.any? { |e| e[:id] }
62+
end
63+
64+
true
65+
end
66+
67+
def self.process_roll(notation)
68+
begin
69+
formatted = Rollmaster::DiceEngine.format_notation(notation).first
70+
final = Rollmaster::DiceEngine.roll(notation).first
71+
{ error: false, formatted: formatted, result: final }
72+
rescue Rollmaster::DiceEngine::RollError => e
73+
Rails.logger.warn("Rollmaster: Error formatting notation for post #{post.id}: #{e.message}")
74+
{ error: true, formatted: nil, result: e.message }
75+
end
76+
end
77+
78+
def self.match_rolls(rolls, post)
79+
existing_rolls = Rollmaster::Roll.where(post_id: post.id).to_a
80+
return if existing_rolls.empty?
81+
82+
rolls
83+
.reject { |r| r[:raw].empty? || r[:error] }
84+
.each do |roll|
85+
existing_roll_idx =
86+
existing_rolls.index { |r| r.raw == roll[:raw] || r.notation == roll[:formatted] }
87+
next if existing_roll_idx.nil?
88+
89+
existing_roll = existing_rolls[existing_roll_idx]
90+
roll[:id] = existing_roll.id
91+
roll[:result] = existing_roll.result # use existing roll result
92+
existing_rolls.delete_at(existing_roll_idx)
93+
end
94+
end
1195

12-
# create new dice rolls for any new ones
96+
def self.save_rolls(rolls, post)
97+
rolls
98+
.reject { |r| r[:raw].empty? || r[:error] }
99+
.each do |roll|
100+
if roll[:id]
101+
existing_roll = Rollmaster::Roll.find(roll[:id])
102+
existing_roll.update!(raw: roll[:raw], notation: roll[:formatted])
103+
else
104+
new_roll =
105+
Rollmaster::Roll.create!(
106+
post_id: post.id,
107+
raw: roll[:raw],
108+
notation: roll[:formatted],
109+
result: roll[:result],
110+
)
111+
roll[:id] = new_roll.id
112+
end
113+
end
13114
end
14115
end
15116
end

plugin.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ module ::Rollmaster
2626
# I don't think this is needed, but it doesn't hurt to be safe
2727
::Rollmaster::DiceEngine.reset_context
2828

29-
on(:post_process_cooked) { |doc, post| ::Rollmaster::HandleCookedPostProcess.process(doc, post) }
29+
on(:before_post_process_cooked) do |doc, post|
30+
::Rollmaster::HandleCookedPostProcess.process(doc, post) if SiteSetting.rollmaster_enabled
31+
end
32+
3033
# TODO: consider :chat_message_processed as well
3134
end

spec/integration/bbcode_spec.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe "Rollmaster BBCode integration", type: :integration do
4+
before do
5+
enable_current_plugin
6+
Jobs.run_immediately!
7+
end
8+
9+
it "processes [roll] BBCode and creates a Roll record" do
10+
post = Fabricate(:post, raw: <<~MD)
11+
[roll]2d6[/roll]
12+
MD
13+
post.save
14+
cpp = CookedPostProcessor.new(post)
15+
cpp.post_process
16+
17+
roll = ::Rollmaster::Roll.find_by(post_id: post.id)
18+
19+
expect(roll).not_to be_nil
20+
expect(roll.post_id).to eq(post.id)
21+
expect(roll.raw).to eq("2d6")
22+
expect(cpp.html).to include("data-roll-id=\"#{roll.id}\"")
23+
end
24+
25+
it "handles multiple [roll] BBCode in a single post" do
26+
post = Fabricate(:post, raw: <<~MD)
27+
Here are some rolls:
28+
[roll]1d20[/roll]
29+
[roll]3d8+2[/roll]
30+
[roll]4d6kh3[/roll]
31+
MD
32+
post.save
33+
cpp = CookedPostProcessor.new(post)
34+
cpp.post_process
35+
36+
rolls = ::Rollmaster::Roll.where(post_id: post.id).to_a
37+
38+
expect(rolls.size).to eq(3)
39+
expect(rolls.map(&:raw)).to contain_exactly("1d20", "3d8+2", "4d6kh3")
40+
rolls.each { |roll| expect(cpp.html).to include("data-roll-id=\"#{roll.id}\"") }
41+
end
42+
43+
it "reuses existing rolls when a post is edited" do
44+
post = Fabricate(:post, raw: <<~MD)
45+
Initial roll: [roll]1d6[/roll]
46+
MD
47+
post.save
48+
cpp = CookedPostProcessor.new(post)
49+
cpp.post_process
50+
51+
initial_roll = ::Rollmaster::Roll.find_by(post_id: post.id)
52+
expect(initial_roll).not_to be_nil
53+
expect(initial_roll.raw).to eq("1d6")
54+
55+
# Edit the post to change the roll
56+
post.raw = <<~MD
57+
Updated rolls:
58+
[roll]1d6[/roll]
59+
[roll]1d4+1[/roll]
60+
MD
61+
post.save
62+
cpp = CookedPostProcessor.new(post)
63+
cpp.post_process
64+
65+
rolls = ::Rollmaster::Roll.where(post_id: post.id).to_a
66+
expect(rolls.size).to eq(2)
67+
expect(rolls.map(&:raw)).to contain_exactly("1d6", "1d4+1")
68+
expect(initial_roll.id).in?(rolls.map(&:id))
69+
rolls.each { |roll| expect(cpp.html).to include("data-roll-id=\"#{roll.id}\"") }
70+
end
71+
end

0 commit comments

Comments
 (0)