coplan-engine 0.2.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 +350 -7
- 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 +41 -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 +57 -0
- data/app/helpers/coplan/comments_helper.rb +4 -3
- data/app/helpers/coplan/markdown_helper.rb +7 -1
- data/app/javascript/controllers/coplan/comment_nav_controller.js +126 -13
- 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 +203 -56
- 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/apply_operations.rb +43 -0
- data/app/services/coplan/plans/commit_session.rb +26 -1
- data/app/services/coplan/plans/markdown_text_extractor.rb +142 -0
- data/app/services/coplan/plans/position_resolver.rb +111 -0
- data/app/views/coplan/comment_threads/_reply_form.html.erb +1 -1
- data/app/views/coplan/comment_threads/_thread_popover.html.erb +4 -4
- 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/app/views/layouts/coplan/application.html.erb +2 -0
- data/db/migrate/20260327000000_create_coplan_plan_viewers.rb +15 -0
- data/lib/coplan/version.rb +1 -1
- metadata +8 -1
|
@@ -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
|
|
@@ -20,6 +20,8 @@ module CoPlan
|
|
|
20
20
|
resolve_insert_under_heading
|
|
21
21
|
when "delete_paragraph_containing"
|
|
22
22
|
resolve_delete_paragraph_containing
|
|
23
|
+
when "replace_section"
|
|
24
|
+
resolve_replace_section
|
|
23
25
|
else
|
|
24
26
|
raise OperationError, "Unknown operation: #{@op["op"]}"
|
|
25
27
|
end
|
|
@@ -153,6 +155,115 @@ module CoPlan
|
|
|
153
155
|
paragraphs
|
|
154
156
|
end
|
|
155
157
|
|
|
158
|
+
def resolve_replace_section
|
|
159
|
+
heading = @op["heading"]
|
|
160
|
+
raise OperationError, "replace_section requires 'heading'" if heading.blank?
|
|
161
|
+
raise OperationError, "replace_section requires 'new_content'" if @op["new_content"].nil?
|
|
162
|
+
|
|
163
|
+
include_heading = @op.fetch("include_heading", true)
|
|
164
|
+
# Normalize: accept both string and boolean
|
|
165
|
+
include_heading = include_heading != false && include_heading != "false"
|
|
166
|
+
|
|
167
|
+
headings = parse_headings(@content)
|
|
168
|
+
matches = headings.select { |h| h[:text] == heading }
|
|
169
|
+
|
|
170
|
+
if matches.empty?
|
|
171
|
+
raise OperationError, "replace_section: heading_not_found — no heading matching '#{heading}'"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if matches.length > 1
|
|
175
|
+
match_details = matches.map { |m| { heading: m[:text], line: m[:line] } }
|
|
176
|
+
raise OperationError, "replace_section: ambiguous_heading — found #{matches.length} headings matching '#{heading}': #{match_details.inspect}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
match = matches.first
|
|
180
|
+
target_level = match[:level]
|
|
181
|
+
|
|
182
|
+
# Section starts at the heading line start
|
|
183
|
+
section_start = match[:line_start]
|
|
184
|
+
|
|
185
|
+
# Section ends at the next heading of equal or higher level, or EOF
|
|
186
|
+
next_heading = headings.find { |h| h[:line_start] > match[:line_start] && h[:level] <= target_level }
|
|
187
|
+
section_end = next_heading ? next_heading[:line_start] : @content.length
|
|
188
|
+
|
|
189
|
+
# Strip all trailing newlines from the section range so the separator
|
|
190
|
+
# between sections falls outside the replaced range. This ensures
|
|
191
|
+
# replacement content won't merge into the next heading.
|
|
192
|
+
section_end = section_end.to_i
|
|
193
|
+
while section_end > section_start && @content[section_end - 1] == "\n"
|
|
194
|
+
section_end -= 1
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
range = if include_heading
|
|
198
|
+
[section_start, section_end]
|
|
199
|
+
else
|
|
200
|
+
# Skip past the heading line itself
|
|
201
|
+
heading_line_end = @content.index("\n", section_start)
|
|
202
|
+
if heading_line_end
|
|
203
|
+
body_start = heading_line_end + 1
|
|
204
|
+
# Skip blank line after heading
|
|
205
|
+
while body_start < section_end && @content[body_start] == "\n"
|
|
206
|
+
body_start += 1
|
|
207
|
+
end
|
|
208
|
+
# When trailing newlines are stripped, section_end can retreat
|
|
209
|
+
# behind body_start. Use an empty range at body_start to avoid
|
|
210
|
+
# an inverted range and keep the insertion point after the heading newline.
|
|
211
|
+
[body_start, [body_start, section_end].max]
|
|
212
|
+
else
|
|
213
|
+
# Heading is the only line — body is empty
|
|
214
|
+
[section_end, section_end]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
Resolution.new(op: "replace_section", ranges: [range])
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Parse markdown headings, respecting code fences (``` blocks).
|
|
222
|
+
# Returns an array of hashes: { text:, level:, line:, line_start:, line_end: }
|
|
223
|
+
def parse_headings(content)
|
|
224
|
+
headings = []
|
|
225
|
+
in_code_fence = false
|
|
226
|
+
fence_char = nil
|
|
227
|
+
fence_length = 0
|
|
228
|
+
line_number = 0
|
|
229
|
+
pos = 0
|
|
230
|
+
|
|
231
|
+
content.each_line do |line|
|
|
232
|
+
line_number += 1
|
|
233
|
+
line_start = pos
|
|
234
|
+
line_end = pos + line.length
|
|
235
|
+
stripped = line.chomp
|
|
236
|
+
|
|
237
|
+
fence_match = stripped.match(/\A(`{3,}|~{3,})(.*)\z/)
|
|
238
|
+
if fence_match
|
|
239
|
+
fence_chars = fence_match[1]
|
|
240
|
+
info_string = fence_match[2]
|
|
241
|
+
if in_code_fence
|
|
242
|
+
# Close only if fence char and length match, and no info string
|
|
243
|
+
if fence_chars[0] == fence_char && fence_chars.length >= fence_length && info_string.strip.empty?
|
|
244
|
+
in_code_fence = false
|
|
245
|
+
end
|
|
246
|
+
else
|
|
247
|
+
in_code_fence = true
|
|
248
|
+
fence_char = fence_chars[0]
|
|
249
|
+
fence_length = fence_chars.length
|
|
250
|
+
end
|
|
251
|
+
elsif !in_code_fence && (m = stripped.match(/\A(\#{1,6})\s+(.+)/))
|
|
252
|
+
headings << {
|
|
253
|
+
level: m[1].length,
|
|
254
|
+
text: stripped,
|
|
255
|
+
line: line_number,
|
|
256
|
+
line_start: line_start,
|
|
257
|
+
line_end: line_end
|
|
258
|
+
}
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
pos = line_end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
headings
|
|
265
|
+
end
|
|
266
|
+
|
|
156
267
|
# Determine the character range to delete so that removing
|
|
157
268
|
# content[range[0]...range[1]] produces clean output with
|
|
158
269
|
# correct paragraph spacing.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
|
|
2
2
|
<%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
|
|
3
3
|
<div class="form-group">
|
|
4
|
-
<textarea name="comment[body_markdown]" rows="2" placeholder="
|
|
4
|
+
<textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
|
|
5
5
|
data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
|
|
6
6
|
</div>
|
|
7
7
|
<button type="submit" class="btn btn--secondary btn--sm">Reply</button>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
<div popover="auto" id="<%= dom_id(thread) %>_popover" class="thread-popover">
|
|
9
9
|
<div class="thread-popover__header">
|
|
10
|
-
<span class="badge badge--<%= thread.
|
|
10
|
+
<span class="badge badge--<%= thread.status %>"><%= thread.status %></span>
|
|
11
11
|
<% if thread.out_of_date? %>
|
|
12
12
|
<span class="badge badge--abandoned">out of date</span>
|
|
13
13
|
<% end %>
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
<div class="thread-popover__reply">
|
|
33
33
|
<%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
|
|
34
34
|
<div class="form-group">
|
|
35
|
-
<textarea name="comment[body_markdown]" rows="2" placeholder="
|
|
35
|
+
<textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
|
|
36
36
|
data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
|
|
37
37
|
</div>
|
|
38
38
|
<button type="submit" class="btn btn--secondary btn--sm">Reply</button>
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
<div class="thread-popover__actions">
|
|
43
43
|
<% if is_plan_author %>
|
|
44
44
|
<% if thread.status == "pending" %>
|
|
45
|
-
<%= button_to "Accept", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
|
|
45
|
+
<%= button_to "Accept (a)", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "accept" } %>
|
|
46
46
|
<% end %>
|
|
47
|
-
<%= button_to "Discard", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
|
|
47
|
+
<%= button_to "Discard (d)", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "discard" } %>
|
|
48
48
|
<% end %>
|
|
49
49
|
</div>
|
|
50
50
|
<% else %>
|
|
@@ -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;">
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
7
|
<%= csp_meta_tag %>
|
|
8
|
+
<%= coplan_favicon_tag %>
|
|
8
9
|
<%= yield :head %>
|
|
9
10
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
<%= link_to coplan.root_path, class: "site-nav__brand" do %>
|
|
19
20
|
<%= image_tag "coplan/coplan-logo-sm.png", alt: "CoPlan", class: "site-nav__logo" %>
|
|
20
21
|
CoPlan
|
|
22
|
+
<%= coplan_environment_badge %>
|
|
21
23
|
<% end %>
|
|
22
24
|
<ul class="site-nav__links">
|
|
23
25
|
<% if signed_in? %>
|
|
@@ -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
|