diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index 48ec8648..d4ebd5a9 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -132,12 +132,23 @@ def toggle_checkbox return end - checkbox_pattern = /\A\s*[*+-]\s+\[[ xX]\]\s/ + checkbox_pattern = MarkdownHelper::TASK_LINE_PATTERN unless old_text.match?(checkbox_pattern) && new_text.match?(checkbox_pattern) render json: { error: "old_text and new_text must be task list items" }, status: :unprocessable_content return end + # Optional source line number scoping the replacement to a single-line + # box, so identical task lines elsewhere in the document can't collide. + line = nil + if params[:line].present? + line = Integer(params[:line], exception: false) + if line.nil? || line < 1 + render json: { error: "line must be a positive integer" }, status: :unprocessable_content + return + end + end + ActiveRecord::Base.transaction do @plan.lock! @plan.reload @@ -148,9 +159,11 @@ def toggle_checkbox end current_content = @plan.current_content || "" + operation = { "op" => "replace_exact", "old_text" => old_text, "new_text" => new_text } + operation["lines"] = line if line result = Plans::ApplyOperations.call( content: current_content, - operations: [{ "op" => "replace_exact", "old_text" => old_text, "new_text" => new_text }] + operations: [operation] ) new_revision = @plan.current_revision + 1 diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index f7029415..26ac5040 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -14,7 +14,13 @@ module MarkdownHelper details summary ].freeze - ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target data-mention-username].freeze + ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line data-line-text data-action data-coplan--checkbox-target data-mention-username data-sourcepos].freeze + + # A source line the toggle endpoint will accept as a task item. Must stay + # in sync with what Commonmarker's tasklist extension can toggle: a + # checkbox is only wired up when its source line matches, so the client + # never offers a toggle the server would reject or mis-target. + TASK_LINE_PATTERN = /\A\s*[*+-]\s+\[[ xX]\]\s/ # Matches `[@username](mention:username)` where the bracket text and link # target encode the same username. Username allows letters, digits, dots, @@ -23,7 +29,11 @@ module MarkdownHelper MENTION_PATTERN = /\[@([\w.-]+)\]\(mention:\1\)/ def render_markdown(content, interactive: true) - html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil }) + render_options = { unsafe: true } + # Sourcepos is only needed to wire checkboxes to their source lines; + # make_checkboxes_interactive strips it from the final output. + render_options[:sourcepos] = true if interactive + html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: render_options }, plugins: { syntax_highlighter: nil }) with_chips = transform_mention_anchors(html) sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized @@ -70,24 +80,31 @@ def render_line_view(content) private + # Wires rendered task checkboxes to their source lines via Commonmarker's + # sourcepos metadata, so the parser that decides what renders as a + # checkbox is also the authority on which line it came from. A checkbox + # only becomes interactive when its own source line matches + # TASK_LINE_PATTERN — constructs Commonmarker renders but the toggle + # endpoint won't accept (ordered-list or blockquoted tasks) stay disabled + # rather than being paired with some other line's text. def make_checkboxes_interactive(html, content) doc = Nokogiri::HTML::DocumentFragment.parse(html) - checkboxes = doc.css('input[type="checkbox"]') - return html if checkboxes.empty? + source_lines = content.to_s.each_line.map(&:rstrip) - task_lines = extract_task_lines(content) + doc.css('input[type="checkbox"]').each do |cb| + li = cb.ancestors("li").first + line_number = sourcepos_start_line(li) + next unless line_number - checkboxes.each_with_index do |cb, i| - line_text = task_lines[i] - next unless line_text + line_text = source_lines[line_number - 1] + next unless line_text&.match?(TASK_LINE_PATTERN) cb.remove_attribute("disabled") cb["data-action"] = "coplan--checkbox#toggle" cb["data-coplan--checkbox-target"] = "checkbox" cb["data-line-text"] = line_text + cb["data-line"] = line_number.to_s - li = cb.parent - next unless li&.name == "li" li.add_class("task-list-item") # Wrap li contents in a