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.
@@ -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
- <%= render partial: "coplan/plans/header", locals: { plan: @plan } %>
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
@@ -1,3 +1,3 @@
1
1
  module CoPlan
2
- VERSION = "0.4.0"
2
+ VERSION = "1.0.0"
3
3
  end
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.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