coplan-engine 0.1.3 → 0.4.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 +268 -85
- data/app/controllers/coplan/api/v1/comments_controller.rb +7 -7
- data/app/controllers/coplan/api/v1/operations_controller.rb +38 -0
- data/app/controllers/coplan/comment_threads_controller.rb +26 -72
- data/app/controllers/coplan/plans_controller.rb +1 -3
- data/app/helpers/coplan/application_helper.rb +44 -0
- data/app/helpers/coplan/markdown_helper.rb +1 -0
- data/app/javascript/controllers/coplan/comment_form_controller.js +14 -0
- data/app/javascript/controllers/coplan/comment_nav_controller.js +247 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +143 -169
- data/app/jobs/coplan/automated_review_job.rb +4 -4
- data/app/models/coplan/comment_thread.rb +13 -7
- data/app/policies/coplan/comment_thread_policy.rb +1 -1
- 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/position_resolver.rb +111 -0
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_reply_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_thread.html.erb +3 -6
- data/app/views/coplan/comment_threads/_thread_popover.html.erb +58 -0
- data/app/views/coplan/plans/show.html.erb +22 -30
- data/app/views/layouts/coplan/application.html.erb +5 -0
- data/config/routes.rb +2 -2
- data/db/migrate/20260320145453_migrate_comment_thread_statuses.rb +31 -0
- data/lib/coplan/version.rb +1 -1
- metadata +5 -2
- data/app/javascript/controllers/coplan/tabs_controller.js +0 -18
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
|
-
static targets = ["content", "popover", "form", "anchorInput", "contextInput", "occurrenceInput", "anchorPreview", "anchorQuote"]
|
|
4
|
+
static targets = ["content", "popover", "form", "anchorInput", "contextInput", "occurrenceInput", "anchorPreview", "anchorQuote", "margin", "threads"]
|
|
5
5
|
static values = { planId: String }
|
|
6
6
|
|
|
7
7
|
connect() {
|
|
8
8
|
this.selectedText = null
|
|
9
|
+
this._activeMark = null
|
|
10
|
+
this._activePopover = null
|
|
9
11
|
this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
|
|
10
12
|
document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
|
|
13
|
+
this._handleScroll = this._handleScroll.bind(this)
|
|
14
|
+
window.addEventListener("scroll", this._handleScroll, { passive: true })
|
|
11
15
|
this.highlightAnchors()
|
|
12
|
-
|
|
16
|
+
|
|
17
|
+
// Watch for broadcast-appended threads and re-highlight
|
|
18
|
+
if (this.hasThreadsTarget) {
|
|
19
|
+
this._threadsObserver = new MutationObserver(() => this.highlightAnchors())
|
|
20
|
+
this._threadsObserver.observe(this.threadsTarget, { childList: true })
|
|
21
|
+
}
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
disconnect() {
|
|
16
25
|
this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
|
|
17
26
|
document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
|
|
18
|
-
|
|
27
|
+
window.removeEventListener("scroll", this._handleScroll)
|
|
28
|
+
if (this._threadsObserver) {
|
|
29
|
+
this._threadsObserver.disconnect()
|
|
30
|
+
this._threadsObserver = null
|
|
31
|
+
}
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
handleMouseUp(event) {
|
|
@@ -32,17 +45,33 @@ export default class extends Controller {
|
|
|
32
45
|
|
|
33
46
|
checkSelection(event) {
|
|
34
47
|
const selection = window.getSelection()
|
|
35
|
-
const text = selection.toString().trim()
|
|
36
48
|
|
|
37
|
-
if (
|
|
38
|
-
|
|
49
|
+
if (!selection.rangeCount) return
|
|
50
|
+
const range = selection.getRangeAt(0)
|
|
51
|
+
|
|
52
|
+
// Make sure at least part of the selection is within the content area.
|
|
53
|
+
// Whole-line selections (e.g. triple-click) can set commonAncestorContainer
|
|
54
|
+
// to a parent element above contentTarget, so we check start/end individually.
|
|
55
|
+
const startInContent = this.contentTarget.contains(range.startContainer)
|
|
56
|
+
const endInContent = this.contentTarget.contains(range.endContainer)
|
|
57
|
+
if (!startInContent) {
|
|
39
58
|
return
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
// Clamp the range to the last rendered markdown element, not the
|
|
62
|
+
// content wrapper's lastChild (which is a hidden popover/form control).
|
|
63
|
+
if (startInContent && !endInContent) {
|
|
64
|
+
const clampTarget = this.hasPopoverTarget
|
|
65
|
+
? this.popoverTarget.previousElementSibling || this.popoverTarget.previousSibling
|
|
66
|
+
: this.contentTarget.lastChild
|
|
67
|
+
if (clampTarget) range.setEndAfter(clampTarget)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Extract text after clamping so it only contains content-area text
|
|
71
|
+
const text = selection.toString().trim()
|
|
72
|
+
|
|
73
|
+
if (text.length < 3) {
|
|
74
|
+
this.popoverTarget.style.display = "none"
|
|
46
75
|
return
|
|
47
76
|
}
|
|
48
77
|
|
|
@@ -72,14 +101,9 @@ export default class extends Controller {
|
|
|
72
101
|
: this.selectedText
|
|
73
102
|
this.anchorPreviewTarget.style.display = "block"
|
|
74
103
|
|
|
75
|
-
// Position
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const offsetTop = popoverRect.top - layoutRect.top
|
|
79
|
-
this.formTarget.style.position = "absolute"
|
|
80
|
-
this.formTarget.style.top = `${offsetTop}px`
|
|
81
|
-
|
|
82
|
-
// Show form, hide popover
|
|
104
|
+
// Position form where the popover was, then show it
|
|
105
|
+
this.formTarget.style.top = this.popoverTarget.style.top
|
|
106
|
+
this.formTarget.style.left = this.popoverTarget.style.left
|
|
83
107
|
this.formTarget.style.display = "block"
|
|
84
108
|
this.popoverTarget.style.display = "none"
|
|
85
109
|
|
|
@@ -108,7 +132,10 @@ export default class extends Controller {
|
|
|
108
132
|
if (event.detail.success) {
|
|
109
133
|
const form = event.target
|
|
110
134
|
const textarea = form.querySelector("textarea")
|
|
111
|
-
if (textarea)
|
|
135
|
+
if (textarea) {
|
|
136
|
+
textarea.value = ""
|
|
137
|
+
textarea.blur()
|
|
138
|
+
}
|
|
112
139
|
}
|
|
113
140
|
}
|
|
114
141
|
|
|
@@ -129,7 +156,6 @@ export default class extends Controller {
|
|
|
129
156
|
const anchor = event.currentTarget.dataset.anchor
|
|
130
157
|
if (!anchor) return
|
|
131
158
|
|
|
132
|
-
const context = event.currentTarget.closest("[data-anchor-context]")?.dataset.anchorContext || ""
|
|
133
159
|
const occurrence = event.currentTarget.dataset.anchorOccurrence
|
|
134
160
|
|
|
135
161
|
// Remove existing highlights first
|
|
@@ -140,13 +166,64 @@ export default class extends Controller {
|
|
|
140
166
|
// Build full text for position lookups
|
|
141
167
|
this.fullText = this.contentTarget.textContent
|
|
142
168
|
|
|
143
|
-
|
|
144
|
-
const highlighted = this.findAndHighlightForThread(anchor, context, occurrence, "anchor-highlight--active", null)
|
|
169
|
+
const highlighted = this.findAndHighlight(anchor, occurrence, "anchor-highlight--active")
|
|
145
170
|
if (highlighted) {
|
|
146
171
|
highlighted.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
147
172
|
}
|
|
148
173
|
}
|
|
149
174
|
|
|
175
|
+
openThreadPopover(event) {
|
|
176
|
+
const threadId = event.currentTarget.dataset.threadId
|
|
177
|
+
if (!threadId) return
|
|
178
|
+
|
|
179
|
+
const popover = document.getElementById(`${threadId}_popover`)
|
|
180
|
+
if (!popover) return
|
|
181
|
+
|
|
182
|
+
const trigger = event.currentTarget
|
|
183
|
+
|
|
184
|
+
popover.style.visibility = "hidden"
|
|
185
|
+
popover.showPopover()
|
|
186
|
+
this._positionPopoverAtMark(popover, trigger)
|
|
187
|
+
popover.style.visibility = "visible"
|
|
188
|
+
|
|
189
|
+
this._activeMark = trigger
|
|
190
|
+
this._activePopover = popover
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_handleScroll() {
|
|
194
|
+
if (!this._activeMark || !this._activePopover) return
|
|
195
|
+
try {
|
|
196
|
+
if (!this._activePopover.matches(":popover-open")) {
|
|
197
|
+
this._activeMark = null
|
|
198
|
+
this._activePopover = null
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
} catch { return }
|
|
202
|
+
this._positionPopoverAtMark(this._activePopover, this._activeMark)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_positionPopoverAtMark(popover, mark) {
|
|
206
|
+
const markRect = mark.getBoundingClientRect()
|
|
207
|
+
const popoverRect = popover.getBoundingClientRect()
|
|
208
|
+
const viewportWidth = window.innerWidth
|
|
209
|
+
const viewportHeight = window.innerHeight
|
|
210
|
+
|
|
211
|
+
let top = markRect.top
|
|
212
|
+
let left = markRect.right + 12
|
|
213
|
+
|
|
214
|
+
if (left + popoverRect.width > viewportWidth - 16) {
|
|
215
|
+
left = markRect.left - popoverRect.width - 12
|
|
216
|
+
}
|
|
217
|
+
if (top + popoverRect.height > viewportHeight - 16) {
|
|
218
|
+
top = viewportHeight - popoverRect.height - 16
|
|
219
|
+
}
|
|
220
|
+
if (top < 16) top = 16
|
|
221
|
+
if (left < 16) left = 16
|
|
222
|
+
|
|
223
|
+
popover.style.top = `${top}px`
|
|
224
|
+
popover.style.left = `${left}px`
|
|
225
|
+
}
|
|
226
|
+
|
|
150
227
|
extractContext(range, selectedText) {
|
|
151
228
|
// Grab surrounding text for disambiguation
|
|
152
229
|
const fullText = this.contentTarget.textContent
|
|
@@ -214,105 +291,66 @@ export default class extends Controller {
|
|
|
214
291
|
})
|
|
215
292
|
this.contentTarget.normalize()
|
|
216
293
|
|
|
294
|
+
// Clear margin dots
|
|
295
|
+
if (this.hasMarginTarget) {
|
|
296
|
+
this.marginTarget.innerHTML = ""
|
|
297
|
+
}
|
|
298
|
+
|
|
217
299
|
// Build full text once for position lookups
|
|
218
300
|
this.fullText = this.contentTarget.textContent
|
|
219
301
|
|
|
220
302
|
const threads = this.element.querySelectorAll("[data-anchor-text]")
|
|
221
303
|
threads.forEach(thread => {
|
|
222
304
|
const anchor = thread.dataset.anchorText
|
|
223
|
-
const context = thread.dataset.anchorContext
|
|
224
305
|
const occurrence = thread.dataset.anchorOccurrence
|
|
306
|
+
const status = thread.dataset.threadStatus || "pending"
|
|
307
|
+
const threadId = thread.id
|
|
308
|
+
|
|
225
309
|
if (anchor && anchor.length > 0) {
|
|
226
|
-
|
|
310
|
+
const isOpen = status === "pending" || status === "todo"
|
|
311
|
+
const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
|
|
312
|
+
const specificClass = isOpen ? `anchor-highlight--${status}` : ""
|
|
313
|
+
const classes = `anchor-highlight ${statusClass} ${specificClass}`.trim()
|
|
314
|
+
const mark = this.findAndHighlight(anchor, occurrence, classes)
|
|
315
|
+
|
|
316
|
+
if (mark && threadId) {
|
|
317
|
+
// Make highlight clickable to open popover
|
|
318
|
+
mark.dataset.threadId = threadId
|
|
319
|
+
mark.style.cursor = "pointer"
|
|
320
|
+
mark.addEventListener("click", (e) => this.openThreadPopover(e))
|
|
321
|
+
|
|
322
|
+
// Create margin dot
|
|
323
|
+
if (this.hasMarginTarget) {
|
|
324
|
+
this.createMarginDot(mark, threadId, status)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
227
327
|
}
|
|
228
328
|
})
|
|
229
|
-
|
|
230
|
-
this.positionThreads()
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Re-highlight and reposition when threads are added/removed via turbo stream broadcasts
|
|
234
|
-
observeThreadLists() {
|
|
235
|
-
this.threadListObserver = new MutationObserver(() => {
|
|
236
|
-
// Debounce — multiple mutations may fire in quick succession
|
|
237
|
-
clearTimeout(this._repositionTimer)
|
|
238
|
-
this._repositionTimer = setTimeout(() => this.highlightAnchors(), 50)
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
this.element.querySelectorAll(".comment-threads-list").forEach(list => {
|
|
242
|
-
this.threadListObserver.observe(list, { childList: true })
|
|
243
|
-
})
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
repositionThreads() {
|
|
247
|
-
// Small delay to let the tab panel become visible before measuring positions
|
|
248
|
-
setTimeout(() => this.positionThreads(), 10)
|
|
249
329
|
}
|
|
250
330
|
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
positionThreadList(selector, sidebar) {
|
|
270
|
-
const threadList = this.element.querySelector(selector)
|
|
271
|
-
if (!threadList) return
|
|
272
|
-
|
|
273
|
-
const threads = Array.from(threadList.querySelectorAll(".comment-thread"))
|
|
274
|
-
if (threads.length === 0) return
|
|
275
|
-
|
|
276
|
-
const sidebarRect = sidebar.getBoundingClientRect()
|
|
277
|
-
|
|
278
|
-
// Sort threads by their anchor's vertical position in the document
|
|
279
|
-
threads.sort((a, b) => {
|
|
280
|
-
const markA = a._highlightMark
|
|
281
|
-
const markB = b._highlightMark
|
|
282
|
-
const yA = markA ? markA.getBoundingClientRect().top : Infinity
|
|
283
|
-
const yB = markB ? markB.getBoundingClientRect().top : Infinity
|
|
284
|
-
return yA - yB
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
// Reorder DOM within this list only
|
|
288
|
-
threads.forEach(thread => threadList.appendChild(thread))
|
|
289
|
-
|
|
290
|
-
// Position threads vertically
|
|
291
|
-
const gap = 8
|
|
292
|
-
let cursor = 0
|
|
293
|
-
|
|
294
|
-
threads.forEach(thread => {
|
|
295
|
-
const mark = thread._highlightMark
|
|
296
|
-
let desiredY = cursor
|
|
297
|
-
|
|
298
|
-
if (mark) {
|
|
299
|
-
desiredY = mark.getBoundingClientRect().top - sidebarRect.top + sidebar.scrollTop
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const y = Math.max(desiredY, cursor)
|
|
303
|
-
thread.style.marginTop = `${y - cursor}px`
|
|
304
|
-
cursor = y + thread.offsetHeight + gap
|
|
305
|
-
})
|
|
331
|
+
createMarginDot(highlightMark, threadId, status) {
|
|
332
|
+
const contentRect = this.contentTarget.getBoundingClientRect()
|
|
333
|
+
const markRect = highlightMark.getBoundingClientRect()
|
|
334
|
+
const marginRect = this.marginTarget.getBoundingClientRect()
|
|
335
|
+
|
|
336
|
+
const dot = document.createElement("button")
|
|
337
|
+
const isOpen = status === "pending" || status === "todo"
|
|
338
|
+
const openClass = isOpen ? `margin-dot--open margin-dot--${status}` : "margin-dot--resolved"
|
|
339
|
+
dot.className = `margin-dot ${openClass}`
|
|
340
|
+
dot.style.top = `${markRect.top - marginRect.top}px`
|
|
341
|
+
dot.dataset.threadId = threadId
|
|
342
|
+
dot.addEventListener("click", (e) => this.openThreadPopover(e))
|
|
343
|
+
dot.setAttribute("aria-label", `${status} comment`)
|
|
344
|
+
|
|
345
|
+
this.marginTarget.appendChild(dot)
|
|
306
346
|
}
|
|
307
347
|
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
findAndHighlightForThread(text, context, occurrence, className, threadEl) {
|
|
348
|
+
// Find and highlight the Nth occurrence of text in the rendered DOM.
|
|
349
|
+
// Uses the occurrence index from server-side positional data.
|
|
350
|
+
findAndHighlight(text, occurrence, className) {
|
|
312
351
|
const fullText = this.fullText
|
|
313
352
|
let targetIndex = -1
|
|
314
353
|
|
|
315
|
-
// Strategy 1: Use the occurrence index from the server (most reliable)
|
|
316
354
|
if (occurrence !== undefined && occurrence !== "") {
|
|
317
355
|
const occurrenceNum = parseInt(occurrence, 10)
|
|
318
356
|
if (!isNaN(occurrenceNum)) {
|
|
@@ -320,27 +358,9 @@ export default class extends Controller {
|
|
|
320
358
|
}
|
|
321
359
|
}
|
|
322
360
|
|
|
323
|
-
// Strategy 2: Fall back to context matching
|
|
324
|
-
if (targetIndex === -1 && context && context.length > 0) {
|
|
325
|
-
const contextIndex = fullText.indexOf(context)
|
|
326
|
-
if (contextIndex !== -1) {
|
|
327
|
-
targetIndex = fullText.indexOf(text, contextIndex)
|
|
328
|
-
if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
|
|
329
|
-
targetIndex = fullText.indexOf(text)
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Strategy 3: Fall back to first occurrence
|
|
335
|
-
if (targetIndex === -1) {
|
|
336
|
-
targetIndex = fullText.indexOf(text)
|
|
337
|
-
}
|
|
338
|
-
|
|
339
361
|
if (targetIndex === -1) return null
|
|
340
362
|
|
|
341
|
-
|
|
342
|
-
if (mark && threadEl) threadEl._highlightMark = mark
|
|
343
|
-
return mark
|
|
363
|
+
return this.highlightAtIndex(targetIndex, text.length, className)
|
|
344
364
|
}
|
|
345
365
|
|
|
346
366
|
findNthOccurrence(text, search, n) {
|
|
@@ -352,43 +372,6 @@ export default class extends Controller {
|
|
|
352
372
|
return pos
|
|
353
373
|
}
|
|
354
374
|
|
|
355
|
-
findAndHighlightWithContext(text, context, className) {
|
|
356
|
-
// Use context to find the right occurrence of the anchor text
|
|
357
|
-
const fullText = this.fullText
|
|
358
|
-
let targetIndex
|
|
359
|
-
|
|
360
|
-
if (context && context.length > 0) {
|
|
361
|
-
const contextIndex = fullText.indexOf(context)
|
|
362
|
-
if (contextIndex !== -1) {
|
|
363
|
-
// Find the anchor text within the context region
|
|
364
|
-
targetIndex = fullText.indexOf(text, contextIndex)
|
|
365
|
-
if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
|
|
366
|
-
targetIndex = fullText.indexOf(text) // fallback
|
|
367
|
-
}
|
|
368
|
-
} else {
|
|
369
|
-
targetIndex = fullText.indexOf(text)
|
|
370
|
-
}
|
|
371
|
-
} else {
|
|
372
|
-
targetIndex = fullText.indexOf(text)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (targetIndex === -1) return null
|
|
376
|
-
|
|
377
|
-
// Find the thread element to store the mark reference
|
|
378
|
-
const threads = this.element.querySelectorAll(".comment-thread[data-anchor-text]")
|
|
379
|
-
let threadEl = null
|
|
380
|
-
for (const t of threads) {
|
|
381
|
-
if (t.dataset.anchorText === text && t.dataset.anchorContext === (context || "")) {
|
|
382
|
-
threadEl = t
|
|
383
|
-
break
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const mark = this.highlightAtIndex(targetIndex, text.length, className)
|
|
388
|
-
if (mark && threadEl) threadEl._highlightMark = mark
|
|
389
|
-
return mark
|
|
390
|
-
}
|
|
391
|
-
|
|
392
375
|
highlightAtIndex(startIndex, length, className) {
|
|
393
376
|
if (startIndex < 0 || length <= 0) return null
|
|
394
377
|
|
|
@@ -433,13 +416,4 @@ export default class extends Controller {
|
|
|
433
416
|
|
|
434
417
|
return firstHighlighted
|
|
435
418
|
}
|
|
436
|
-
|
|
437
|
-
// Keep legacy method for scrollToAnchor (single-use highlight)
|
|
438
|
-
findAndHighlight(text, className) {
|
|
439
|
-
if (!text || text.length === 0) return null
|
|
440
|
-
const fullText = this.contentTarget.textContent
|
|
441
|
-
const index = fullText.indexOf(text)
|
|
442
|
-
if (index === -1) return null
|
|
443
|
-
return this.highlightAtIndex(index, text.length, className)
|
|
444
|
-
}
|
|
445
419
|
}
|
|
@@ -46,7 +46,7 @@ module CoPlan
|
|
|
46
46
|
plan_version: version,
|
|
47
47
|
created_by_user: created_by,
|
|
48
48
|
anchor_text: item[:anchor_text],
|
|
49
|
-
status: "
|
|
49
|
+
status: "pending"
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
thread.comments.create!(
|
|
@@ -60,10 +60,10 @@ module CoPlan
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def broadcast_new_thread(plan, thread)
|
|
63
|
-
Broadcaster.
|
|
63
|
+
Broadcaster.append_to(
|
|
64
64
|
plan,
|
|
65
|
-
target: "
|
|
66
|
-
partial: "coplan/comment_threads/
|
|
65
|
+
target: "plan-threads",
|
|
66
|
+
partial: "coplan/comment_threads/thread_popover",
|
|
67
67
|
locals: { thread: thread, plan: plan }
|
|
68
68
|
)
|
|
69
69
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module CoPlan
|
|
2
2
|
class CommentThread < ApplicationRecord
|
|
3
|
-
STATUSES = %w[
|
|
3
|
+
STATUSES = %w[pending todo resolved discarded].freeze
|
|
4
|
+
OPEN_STATUSES = %w[pending todo].freeze
|
|
5
|
+
CLOSED_STATUSES = %w[resolved discarded].freeze
|
|
4
6
|
|
|
5
7
|
attr_accessor :anchor_occurrence
|
|
6
8
|
|
|
@@ -16,10 +18,10 @@ module CoPlan
|
|
|
16
18
|
|
|
17
19
|
before_create :resolve_anchor_position
|
|
18
20
|
|
|
19
|
-
scope :open_threads, -> { where(status:
|
|
21
|
+
scope :open_threads, -> { where(status: OPEN_STATUSES) }
|
|
20
22
|
scope :current, -> { where(out_of_date: false) }
|
|
21
|
-
scope :active, -> { where(status:
|
|
22
|
-
scope :archived, -> { where("status
|
|
23
|
+
scope :active, -> { where(status: OPEN_STATUSES, out_of_date: false) }
|
|
24
|
+
scope :archived, -> { where("status NOT IN (?) OR out_of_date = ?", OPEN_STATUSES, true) }
|
|
23
25
|
|
|
24
26
|
# Transforms anchor positions through intervening version edits using OT.
|
|
25
27
|
# Threads without positional data (anchor_start/anchor_end/anchor_revision)
|
|
@@ -94,11 +96,15 @@ module CoPlan
|
|
|
94
96
|
end
|
|
95
97
|
|
|
96
98
|
def accept!(user)
|
|
97
|
-
update!(status: "
|
|
99
|
+
update!(status: "todo", resolved_by_user: user)
|
|
98
100
|
end
|
|
99
101
|
|
|
100
|
-
def
|
|
101
|
-
update!(status: "
|
|
102
|
+
def discard!(user)
|
|
103
|
+
update!(status: "discarded", resolved_by_user: user)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def open?
|
|
107
|
+
OPEN_STATUSES.include?(status)
|
|
102
108
|
end
|
|
103
109
|
|
|
104
110
|
def anchor_valid?
|
|
@@ -21,6 +21,8 @@ module CoPlan
|
|
|
21
21
|
apply_insert_under_heading(op, index)
|
|
22
22
|
when "delete_paragraph_containing"
|
|
23
23
|
apply_delete_paragraph_containing(op, index)
|
|
24
|
+
when "replace_section"
|
|
25
|
+
apply_replace_section(op, index)
|
|
24
26
|
else
|
|
25
27
|
raise OperationError, "Operation #{index}: unknown op '#{op["op"]}'"
|
|
26
28
|
end
|
|
@@ -103,6 +105,47 @@ module CoPlan
|
|
|
103
105
|
applied_data
|
|
104
106
|
end
|
|
105
107
|
|
|
108
|
+
def apply_replace_section(op, index)
|
|
109
|
+
heading = op["heading"]
|
|
110
|
+
new_content = op["new_content"]
|
|
111
|
+
|
|
112
|
+
raise OperationError, "Operation #{index}: replace_section requires 'heading'" if heading.blank?
|
|
113
|
+
raise OperationError, "Operation #{index}: replace_section requires 'new_content'" if new_content.nil?
|
|
114
|
+
|
|
115
|
+
range = if op.key?("_pre_resolved_ranges")
|
|
116
|
+
op["_pre_resolved_ranges"][0]
|
|
117
|
+
else
|
|
118
|
+
Plans::PositionResolver.call(content: @content, operation: op).ranges[0]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# For body-only replacements (include_heading: false), ensure
|
|
122
|
+
# newlines separate the heading from new content and new content
|
|
123
|
+
# from the next section.
|
|
124
|
+
include_heading = op.fetch("include_heading", true)
|
|
125
|
+
include_heading = include_heading != false && include_heading != "false"
|
|
126
|
+
effective_content = new_content
|
|
127
|
+
if !include_heading && range[0] == range[1]
|
|
128
|
+
# Prepend newline if heading line doesn't end with one
|
|
129
|
+
if range[0] > 0 && @content[range[0] - 1] != "\n"
|
|
130
|
+
effective_content = "\n#{effective_content}"
|
|
131
|
+
end
|
|
132
|
+
# Append newline if next content starts without one
|
|
133
|
+
after = @content[range[1]]
|
|
134
|
+
if after && after != "\n" && !effective_content.end_with?("\n")
|
|
135
|
+
effective_content = "#{effective_content}\n"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@content = @content[0...range[0]] + effective_content + @content[range[1]..]
|
|
140
|
+
|
|
141
|
+
delta = effective_content.length - (range[1] - range[0])
|
|
142
|
+
applied_data = op.except("_pre_resolved_ranges")
|
|
143
|
+
applied_data["resolved_range"] = range
|
|
144
|
+
applied_data["new_range"] = [range[0], range[0] + effective_content.length]
|
|
145
|
+
applied_data["delta"] = delta
|
|
146
|
+
applied_data
|
|
147
|
+
end
|
|
148
|
+
|
|
106
149
|
def apply_delete_paragraph_containing(op, index)
|
|
107
150
|
needle = op["needle"]
|
|
108
151
|
|
|
@@ -65,7 +65,7 @@ module CoPlan
|
|
|
65
65
|
rebased_ops = []
|
|
66
66
|
@session.operations_json.each do |op_data|
|
|
67
67
|
op_data = op_data.transform_keys(&:to_s)
|
|
68
|
-
semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count]
|
|
68
|
+
semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count new_content include_heading]
|
|
69
69
|
semantic_op = op_data.slice(*semantic_keys)
|
|
70
70
|
|
|
71
71
|
if op_data["resolved_range"]
|
|
@@ -179,6 +179,31 @@ module CoPlan
|
|
|
179
179
|
|
|
180
180
|
raise SessionConflictError,
|
|
181
181
|
"Paragraph at target position no longer contains '#{op_data["needle"]}'"
|
|
182
|
+
when "replace_section"
|
|
183
|
+
return unless op_data["heading"]
|
|
184
|
+
include_heading = op_data.fetch("include_heading", true)
|
|
185
|
+
include_heading = include_heading != false && include_heading != "false"
|
|
186
|
+
|
|
187
|
+
if include_heading
|
|
188
|
+
first_line_end = content.index("\n", range[0]) || range[1]
|
|
189
|
+
first_line = content[range[0]...[first_line_end, range[1]].min]
|
|
190
|
+
return if first_line&.rstrip == op_data["heading"]&.rstrip
|
|
191
|
+
|
|
192
|
+
raise SessionConflictError,
|
|
193
|
+
"Section heading changed at target position. Expected '#{op_data["heading"]}' " \
|
|
194
|
+
"but found '#{first_line}'"
|
|
195
|
+
else
|
|
196
|
+
search_pos = range[0]
|
|
197
|
+
search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
|
|
198
|
+
heading_line_end = search_pos
|
|
199
|
+
heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
|
|
200
|
+
heading_text = content[heading_line_start...heading_line_end]
|
|
201
|
+
return if heading_text == op_data["heading"]
|
|
202
|
+
|
|
203
|
+
raise SessionConflictError,
|
|
204
|
+
"Section heading before target position changed. Expected '#{op_data["heading"]}' " \
|
|
205
|
+
"but found '#{heading_text}'"
|
|
206
|
+
end
|
|
182
207
|
end
|
|
183
208
|
end
|
|
184
209
|
end
|