coplan-engine 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/coplan/application.css +287 -0
- data/app/channels/coplan/plan_presence_channel.rb +45 -0
- data/app/controllers/coplan/api/v1/comments_controller.rb +2 -2
- data/app/controllers/coplan/api/v1/operations_controller.rb +3 -3
- data/app/controllers/coplan/api/v1/plans_controller.rb +2 -2
- data/app/controllers/coplan/api/v1/sessions_controller.rb +2 -2
- data/app/controllers/coplan/plans_controller.rb +1 -0
- data/app/controllers/coplan/settings/tokens_controller.rb +1 -1
- data/app/helpers/coplan/application_helper.rb +13 -0
- data/app/helpers/coplan/comments_helper.rb +4 -3
- data/app/helpers/coplan/markdown_helper.rb +6 -1
- data/app/javascript/controllers/coplan/content_nav_controller.js +252 -0
- data/app/javascript/controllers/coplan/presence_controller.js +44 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +145 -34
- data/app/models/coplan/comment.rb +4 -0
- data/app/models/coplan/comment_thread.rb +58 -16
- data/app/models/coplan/plan.rb +1 -0
- data/app/models/coplan/plan_viewer.rb +26 -0
- data/app/services/coplan/plans/markdown_text_extractor.rb +142 -0
- data/app/views/coplan/comments/_comment.html.erb +3 -0
- data/app/views/coplan/plans/_header.html.erb +1 -0
- data/app/views/coplan/plans/_viewers.html.erb +16 -0
- data/app/views/coplan/plans/show.html.erb +25 -3
- data/db/migrate/20260327000000_create_coplan_plan_viewers.rb +15 -0
- data/lib/coplan/version.rb +1 -1
- metadata +8 -1
data/app/models/coplan/plan.rb
CHANGED
|
@@ -10,6 +10,7 @@ module CoPlan
|
|
|
10
10
|
has_many :comment_threads, dependent: :destroy
|
|
11
11
|
has_many :edit_sessions, dependent: :destroy
|
|
12
12
|
has_one :edit_lease, dependent: :destroy
|
|
13
|
+
has_many :plan_viewers, dependent: :destroy
|
|
13
14
|
|
|
14
15
|
after_initialize { self.tags ||= [] }
|
|
15
16
|
after_initialize { self.metadata ||= {} }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
class PlanViewer < ApplicationRecord
|
|
3
|
+
STALE_THRESHOLD = 45.seconds
|
|
4
|
+
|
|
5
|
+
belongs_to :plan
|
|
6
|
+
belongs_to :user, class_name: "CoPlan::User"
|
|
7
|
+
|
|
8
|
+
scope :active, -> { where(last_seen_at: STALE_THRESHOLD.ago..) }
|
|
9
|
+
|
|
10
|
+
def self.track(plan:, user:)
|
|
11
|
+
record = find_or_initialize_by(plan: plan, user: user)
|
|
12
|
+
record.update!(last_seen_at: Time.current)
|
|
13
|
+
record
|
|
14
|
+
rescue ActiveRecord::RecordNotUnique
|
|
15
|
+
retry
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.expire(plan:, user:)
|
|
19
|
+
where(plan: plan, user: user).update_all(last_seen_at: STALE_THRESHOLD.ago - 1.second)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.active_viewers_for(plan)
|
|
23
|
+
active.where(plan: plan).joins(:user).includes(:user).order("coplan_users.name").map(&:user)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module CoPlan
|
|
2
|
+
module Plans
|
|
3
|
+
# Extracts plain text from markdown using the Commonmarker AST, returning
|
|
4
|
+
# [stripped_string, position_map] where position_map[i] is the character
|
|
5
|
+
# index in the raw content of stripped_string[i].
|
|
6
|
+
#
|
|
7
|
+
# This handles all markdown constructs: inline formatting (`**`, `*`, `` ` ``,
|
|
8
|
+
# `~~`), tables, links, images, lists, blockquotes, headings, etc.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# stripped, pos_map = MarkdownTextExtractor.call("Hello **world**")
|
|
12
|
+
# # stripped => "Hello world"
|
|
13
|
+
# # pos_map => [0, 1, 2, 3, 4, 5, 8, 9, 10, 11, 12]
|
|
14
|
+
class MarkdownTextExtractor
|
|
15
|
+
def self.call(content)
|
|
16
|
+
new(content).call
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(content)
|
|
20
|
+
@content = content
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
doc = Commonmarker.parse(@content)
|
|
25
|
+
byte_to_char = build_byte_to_char_map
|
|
26
|
+
line_byte_offsets = build_line_byte_offsets
|
|
27
|
+
stripped = +""
|
|
28
|
+
pos_map = []
|
|
29
|
+
|
|
30
|
+
extract_text_nodes(doc, line_byte_offsets, byte_to_char, stripped, pos_map)
|
|
31
|
+
|
|
32
|
+
[stripped, pos_map]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Builds a map from byte offset to character index. Commonmarker reports
|
|
38
|
+
# source positions using byte-based columns, but Ruby string indexing
|
|
39
|
+
# uses character positions.
|
|
40
|
+
def build_byte_to_char_map
|
|
41
|
+
map = {}
|
|
42
|
+
byte_offset = 0
|
|
43
|
+
@content.each_char.with_index do |char, char_idx|
|
|
44
|
+
map[byte_offset] = char_idx
|
|
45
|
+
byte_offset += char.bytesize
|
|
46
|
+
end
|
|
47
|
+
map
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Builds an array mapping 1-based line numbers to byte offsets.
|
|
51
|
+
# line_byte_offsets[line_number] = byte offset of the first byte on that line.
|
|
52
|
+
def build_line_byte_offsets
|
|
53
|
+
offsets = [nil, 0] # index 0 unused; line 1 starts at byte 0
|
|
54
|
+
byte_offset = 0
|
|
55
|
+
@content.each_char do |char|
|
|
56
|
+
byte_offset += char.bytesize
|
|
57
|
+
offsets << byte_offset if char == "\n"
|
|
58
|
+
end
|
|
59
|
+
offsets
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Block-level node types that should be separated by newlines.
|
|
63
|
+
BLOCK_TYPES = %i[paragraph heading table table_row item block_quote list code_block].to_set.freeze
|
|
64
|
+
|
|
65
|
+
# Recursively walks the AST, appending text content to `stripped` and
|
|
66
|
+
# character-index mappings to `pos_map`. Inserts whitespace between
|
|
67
|
+
# block elements and table cells to match browser DOM text behavior.
|
|
68
|
+
def extract_text_nodes(node, line_byte_offsets, byte_to_char, stripped, pos_map)
|
|
69
|
+
prev_was_block = false
|
|
70
|
+
|
|
71
|
+
node.each do |child|
|
|
72
|
+
# Insert a space between adjacent table cells, and a newline
|
|
73
|
+
# between block-level siblings (paragraphs, rows, items, etc.).
|
|
74
|
+
if child.type == :table_cell
|
|
75
|
+
append_separator(stripped, pos_map, " ") if prev_was_block
|
|
76
|
+
prev_was_block = true
|
|
77
|
+
elsif BLOCK_TYPES.include?(child.type)
|
|
78
|
+
append_separator(stripped, pos_map, "\n") if prev_was_block
|
|
79
|
+
prev_was_block = true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
case child.type
|
|
83
|
+
when :text
|
|
84
|
+
pos = child.source_position
|
|
85
|
+
start_byte = line_byte_offsets[pos[:start_line]] + pos[:start_column] - 1
|
|
86
|
+
char_idx = byte_to_char[start_byte]
|
|
87
|
+
child.string_content.each_char.with_index do |char, i|
|
|
88
|
+
stripped << char
|
|
89
|
+
pos_map << (char_idx + i)
|
|
90
|
+
end
|
|
91
|
+
when :code
|
|
92
|
+
# source_position includes backtick delimiters; find the inner
|
|
93
|
+
# content start by scanning past them in the raw string.
|
|
94
|
+
pos = child.source_position
|
|
95
|
+
start_byte = line_byte_offsets[pos[:start_line]] + pos[:start_column] - 1
|
|
96
|
+
node_char_start = byte_to_char[start_byte]
|
|
97
|
+
end_byte = line_byte_offsets[pos[:end_line]] + pos[:end_column] - 1
|
|
98
|
+
node_char_end = byte_to_char[end_byte]
|
|
99
|
+
text = child.string_content
|
|
100
|
+
node_char_len = node_char_end - node_char_start + 1
|
|
101
|
+
tick_len = (node_char_len - text.length) / 2
|
|
102
|
+
content_char_start = node_char_start + tick_len
|
|
103
|
+
text.each_char.with_index do |char, i|
|
|
104
|
+
stripped << char
|
|
105
|
+
pos_map << (content_char_start + i)
|
|
106
|
+
end
|
|
107
|
+
when :code_block
|
|
108
|
+
# Fenced code blocks: source_position spans from the opening
|
|
109
|
+
# fence to the closing fence. The string_content is the inner
|
|
110
|
+
# text (excluding fences). Content starts on the line after
|
|
111
|
+
# the opening fence.
|
|
112
|
+
pos = child.source_position
|
|
113
|
+
text = child.string_content
|
|
114
|
+
content_line = pos[:start_line] + 1
|
|
115
|
+
if content_line <= line_byte_offsets.length - 1
|
|
116
|
+
content_byte = line_byte_offsets[content_line]
|
|
117
|
+
char_idx = byte_to_char[content_byte]
|
|
118
|
+
text.each_char.with_index do |char, i|
|
|
119
|
+
stripped << char
|
|
120
|
+
pos_map << (char_idx + i)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
when :softbreak, :linebreak
|
|
124
|
+
pos = child.source_position
|
|
125
|
+
start_byte = line_byte_offsets[pos[:start_line]] + pos[:start_column] - 1
|
|
126
|
+
stripped << "\n"
|
|
127
|
+
pos_map << byte_to_char[start_byte]
|
|
128
|
+
else
|
|
129
|
+
extract_text_nodes(child, line_byte_offsets, byte_to_char, stripped, pos_map)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Appends a synthetic separator character to the stripped text.
|
|
135
|
+
# Maps it to -1 since it doesn't correspond to any raw source position.
|
|
136
|
+
def append_separator(stripped, pos_map, char)
|
|
137
|
+
stripped << char
|
|
138
|
+
pos_map << -1
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<div class="comment" id="<%= dom_id(comment) %>">
|
|
2
2
|
<div class="comment__header text-sm text-muted">
|
|
3
3
|
<strong><%= comment_author_name(comment) %></strong>
|
|
4
|
+
<% if comment.agent? %>
|
|
5
|
+
<span class="badge badge--agent">agent</span>
|
|
6
|
+
<% end %>
|
|
4
7
|
· <%= time_ago_in_words(comment.created_at) %> ago
|
|
5
8
|
</div>
|
|
6
9
|
<div class="comment__body">
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
</div>
|
|
7
7
|
</div>
|
|
8
8
|
<div class="page-header__actions">
|
|
9
|
+
<%= render partial: "coplan/plans/viewers", locals: { viewers: CoPlan::PlanViewer.active_viewers_for(plan), current_user: current_user } %>
|
|
9
10
|
<% if CoPlan::AutomatedPlanReviewer.enabled.any? %>
|
|
10
11
|
<div class="dropdown" data-controller="coplan--dropdown">
|
|
11
12
|
<button class="btn btn--secondary" data-action="coplan--dropdown#toggle">
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<div id="plan-viewers" class="plan-viewers">
|
|
2
|
+
<% if viewers.any? %>
|
|
3
|
+
<div class="plan-viewers__avatars">
|
|
4
|
+
<% viewers.first(8).each do |viewer| %>
|
|
5
|
+
<% is_you = defined?(current_user) && current_user&.id == viewer.id %>
|
|
6
|
+
<span class="plan-viewers__avatar<%= ' plan-viewers__avatar--you' if is_you %>" data-tooltip="<%= is_you ? "#{viewer.name} (you)" : viewer.name %>">
|
|
7
|
+
<%= viewer.name.split.map { |w| w[0] }.first(2).join.upcase %>
|
|
8
|
+
</span>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% if viewers.size > 8 %>
|
|
11
|
+
<span class="plan-viewers__overflow">+<%= viewers.size - 8 %></span>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<span class="plan-viewers__label"><%= viewers.size %> viewing</span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
@@ -1,12 +1,34 @@
|
|
|
1
|
+
<% content_for(:title, "#{@plan.title} — CoPlan") %>
|
|
2
|
+
<% content_for(:head) do %>
|
|
3
|
+
<meta property="og:title" content="<%= @plan.title %>">
|
|
4
|
+
<meta property="og:description" content="<%= plan_og_description(@plan) %>">
|
|
5
|
+
<meta property="og:type" content="article">
|
|
6
|
+
<meta property="og:site_name" content="CoPlan">
|
|
7
|
+
<meta name="twitter:card" content="summary">
|
|
8
|
+
<meta name="twitter:title" content="<%= @plan.title %>">
|
|
9
|
+
<meta name="twitter:description" content="<%= plan_og_description(@plan) %>">
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
1
12
|
<%= turbo_stream_from @plan %>
|
|
2
13
|
|
|
3
|
-
|
|
14
|
+
<div data-controller="coplan--presence" data-coplan--presence-plan-id-value="<%= @plan.id %>">
|
|
15
|
+
<%= render partial: "coplan/plans/header", locals: { plan: @plan } %>
|
|
16
|
+
</div>
|
|
4
17
|
|
|
5
18
|
<div class="plan-content card">
|
|
6
19
|
<% if @plan.current_content.present? %>
|
|
7
|
-
<div class="plan-layout" data-controller="coplan--text-selection" data-coplan--text-selection-plan-id-value="<%= @plan.id %>">
|
|
20
|
+
<div class="plan-layout" data-controller="coplan--text-selection coplan--content-nav" data-coplan--text-selection-plan-id-value="<%= @plan.id %>" data-action="keydown.esc@document->coplan--text-selection#dismiss">
|
|
21
|
+
<nav class="content-nav" data-coplan--content-nav-target="sidebar" aria-label="Document outline">
|
|
22
|
+
<div class="content-nav__header">
|
|
23
|
+
<span class="content-nav__title">Contents</span>
|
|
24
|
+
<button type="button" class="content-nav__toggle" data-action="coplan--content-nav#toggle" data-coplan--content-nav-target="toggleBtn" aria-label="Hide table of contents" title="Toggle (])">✕</button>
|
|
25
|
+
</div>
|
|
26
|
+
<ul class="content-nav__list" data-coplan--content-nav-target="list"></ul>
|
|
27
|
+
</nav>
|
|
28
|
+
<button type="button" class="content-nav-show-btn" data-action="coplan--content-nav#toggle" data-coplan--content-nav-target="showBtn" title="Show table of contents (])">☰ <kbd>]</kbd></button>
|
|
29
|
+
|
|
8
30
|
<div class="plan-layout__margin" data-coplan--text-selection-target="margin"></div>
|
|
9
|
-
<div class="plan-layout__content" data-coplan--text-selection-target="content">
|
|
31
|
+
<div class="plan-layout__content" data-coplan--text-selection-target="content" data-coplan--content-nav-target="content">
|
|
10
32
|
<%= render_markdown(@plan.current_content) %>
|
|
11
33
|
|
|
12
34
|
<div class="comment-popover" data-coplan--text-selection-target="popover" style="display: none;">
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateCoplanPlanViewers < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :coplan_plan_viewers, id: { type: :string, limit: 36 } do |t|
|
|
4
|
+
t.string :plan_id, limit: 36, null: false
|
|
5
|
+
t.string :user_id, limit: 36, null: false
|
|
6
|
+
t.datetime :last_seen_at, null: false
|
|
7
|
+
t.timestamps
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
add_index :coplan_plan_viewers, [:plan_id, :user_id], unique: true
|
|
11
|
+
add_index :coplan_plan_viewers, :last_seen_at
|
|
12
|
+
add_foreign_key :coplan_plan_viewers, :coplan_plans, column: :plan_id
|
|
13
|
+
add_foreign_key :coplan_plan_viewers, :coplan_users, column: :user_id
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/coplan/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coplan-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Block
|
|
@@ -142,6 +142,7 @@ extra_rdoc_files: []
|
|
|
142
142
|
files:
|
|
143
143
|
- app/assets/images/coplan/coplan-logo-sm.png
|
|
144
144
|
- app/assets/stylesheets/coplan/application.css
|
|
145
|
+
- app/channels/coplan/plan_presence_channel.rb
|
|
145
146
|
- app/controllers/coplan/api/v1/base_controller.rb
|
|
146
147
|
- app/controllers/coplan/api/v1/comments_controller.rb
|
|
147
148
|
- app/controllers/coplan/api/v1/leases_controller.rb
|
|
@@ -161,8 +162,10 @@ files:
|
|
|
161
162
|
- app/helpers/coplan/markdown_helper.rb
|
|
162
163
|
- app/javascript/controllers/coplan/comment_form_controller.js
|
|
163
164
|
- app/javascript/controllers/coplan/comment_nav_controller.js
|
|
165
|
+
- app/javascript/controllers/coplan/content_nav_controller.js
|
|
164
166
|
- app/javascript/controllers/coplan/dropdown_controller.js
|
|
165
167
|
- app/javascript/controllers/coplan/line_selection_controller.js
|
|
168
|
+
- app/javascript/controllers/coplan/presence_controller.js
|
|
166
169
|
- app/javascript/controllers/coplan/text_selection_controller.js
|
|
167
170
|
- app/jobs/coplan/application_job.rb
|
|
168
171
|
- app/jobs/coplan/automated_review_job.rb
|
|
@@ -179,6 +182,7 @@ files:
|
|
|
179
182
|
- app/models/coplan/plan.rb
|
|
180
183
|
- app/models/coplan/plan_collaborator.rb
|
|
181
184
|
- app/models/coplan/plan_version.rb
|
|
185
|
+
- app/models/coplan/plan_viewer.rb
|
|
182
186
|
- app/models/coplan/user.rb
|
|
183
187
|
- app/policies/coplan/application_policy.rb
|
|
184
188
|
- app/policies/coplan/comment_thread_policy.rb
|
|
@@ -189,6 +193,7 @@ files:
|
|
|
189
193
|
- app/services/coplan/plans/apply_operations.rb
|
|
190
194
|
- app/services/coplan/plans/commit_session.rb
|
|
191
195
|
- app/services/coplan/plans/create.rb
|
|
196
|
+
- app/services/coplan/plans/markdown_text_extractor.rb
|
|
192
197
|
- app/services/coplan/plans/operation_error.rb
|
|
193
198
|
- app/services/coplan/plans/position_resolver.rb
|
|
194
199
|
- app/services/coplan/plans/review_prompt_formatter.rb
|
|
@@ -204,6 +209,7 @@ files:
|
|
|
204
209
|
- app/views/coplan/plan_versions/index.html.erb
|
|
205
210
|
- app/views/coplan/plan_versions/show.html.erb
|
|
206
211
|
- app/views/coplan/plans/_header.html.erb
|
|
212
|
+
- app/views/coplan/plans/_viewers.html.erb
|
|
207
213
|
- app/views/coplan/plans/edit.html.erb
|
|
208
214
|
- app/views/coplan/plans/index.html.erb
|
|
209
215
|
- app/views/coplan/plans/show.html.erb
|
|
@@ -219,6 +225,7 @@ files:
|
|
|
219
225
|
- db/migrate/20260226200000_create_coplan_schema.rb
|
|
220
226
|
- db/migrate/20260313210000_expand_content_markdown_to_mediumtext.rb
|
|
221
227
|
- db/migrate/20260320145453_migrate_comment_thread_statuses.rb
|
|
228
|
+
- db/migrate/20260327000000_create_coplan_plan_viewers.rb
|
|
222
229
|
- lib/coplan.rb
|
|
223
230
|
- lib/coplan/configuration.rb
|
|
224
231
|
- lib/coplan/engine.rb
|