Project

General

Profile

Defect #43265 » 0001-Support-task-list-autocompletion.patch

Mizuki ISHIKAWA, 2025-10-07 09:33

View differences:

app/javascript/controllers/list_autofill_controller.js
50 50
}
51 51

  
52 52
class CommonMarkListFormatter {
53
  format(line) {
54
    // Match list items in CommonMark syntax.
55
    // Captures either an ordered list (e.g., '1. ' or '2) ') or an unordered list (e.g., '* ', '- ', '+ ').
56
    // The regex structure:
57
    // ^(\s*)               → leading whitespace
58
    // (?:(\d+)([.)])       → an ordered list marker: number followed by '.' or ')'
59
    // |([*+\-])            → OR an unordered list marker: '*', '+', or '-'
60
    // (.*)$                → the actual list item content
61
    //
62
    // Examples:
63
    // '2. ordered text'           → indent='',  number='2', delimiter='.', bullet=undefined, content='ordered text'
64
    // '  3) nested ordered text'  → indent='  ', number='3', delimiter=')', bullet=undefined, content='nested ordered text'
65
    // '* unordered text'          → indent='', number=undefined, delimiter=undefined, bullet='*', content='unordered text'
66
    // '+ unordered text'          → indent='', number=undefined, delimiter=undefined, bullet='+', content='unordered text'
67
    // '  - nested unordered text' → indent='  ', number=undefined, delimiter=undefined, bullet='-', content='nested unordered text'
68
    const match = line.match(/^(\s*)(?:(\d+)([.)])|([*+\-])) (.*)$/)
69
    if (!match) return null
53
  // Example: '  * text'  → indent='  ', bullet='*', content='text' (or '+' or '-')
54
  #bulletItemPattern  = /^(?<indent>\s*)(?<bullet>[*+\-]) (?<content>.*)$/;
55
  // Example: '  1. text' → indent='  ', num='1', delimiter='.', content='text' (or ')')
56
  #orderedItemPattern = /^(?<indent>\s*)(?<num>\d+)(?<delimiter>[.)]) (?<content>.*)$/;
57
  // Example: '[ ] Task'  → taskContent='Task'
58
  //          '[x] Task'  → taskContent='Task'
59
  #taskAtStartPattern = /^\[[ x]\] (?<taskContent>.*)$/;
70 60

  
71
    const indent = match[1]
72
    const number = match[2]
73
    const delimiter = match[3]
74
    const bullet = match[4]
75
    const content = match[5]
76

  
77
    if (content === '') {
78
      return { action: 'remove' }
61
  format(line) {
62
    const bulletMatch = line.match(this.#bulletItemPattern);
63
    if (bulletMatch) {
64
      return (
65
        this.#formatBulletTask(bulletMatch.groups) ||
66
        this.#formatBulletList(bulletMatch.groups)
67
      );
79 68
    }
80 69

  
81
    if (number) {
82
      const nextNumber = parseInt(number, 10) + 1
83
      return { action: 'insert', text: `${indent}${nextNumber}${delimiter} ` }
84
    } else {
85
      return { action: 'insert', text: `${indent}${bullet} ` }
70
    const orderedMatch = line.match(this.#orderedItemPattern);
71
    if (orderedMatch) {
72
      return (
73
        this.#formatOrderedTask(orderedMatch.groups) ||
74
        this.#formatOrderedList(orderedMatch.groups)
75
      );
86 76
    }
87 77
  }
78

  
79
  // '- [ ] Task' or '* [ ] Task' or '+ [ ] Task'
80
  #formatBulletTask({ indent, bullet, content }) {
81
    const m = content.match(this.#taskAtStartPattern);
82
    if (!m) return null;
83
    const taskContent = m.groups.taskContent;
84

  
85
    return taskContent === ''
86
      ? { action: 'remove' }
87
      : { action: 'insert', text: `${indent}${bullet} [ ] ` };
88
  }
89

  
90
  // '- Item' or '* Item' or '+ Item'
91
  #formatBulletList({ indent, bullet, content }) {
92
    return content === ''
93
      ? { action: 'remove' }
94
      : { action: 'insert', text: `${indent}${bullet} ` };
95
  }
96

  
97
  // '1. [ ] Task' or '1) [ ] Task'
98
  #formatOrderedTask({ indent, num, delimiter, content }) {
99
    const m = content.match(this.#taskAtStartPattern);
100
    if (!m) return null;
101
    const taskContent = m.groups.taskContent;
102

  
103
    const next = `${Number(num) + 1}${delimiter}`;
104
    return taskContent === ''
105
      ? { action: 'remove' }
106
      : { action: 'insert', text: `${indent}${next} [ ] ` };
107
  }
108

  
109
  // '1. Item' or '1) Item'
110
  #formatOrderedList({ indent, num, delimiter, content }) {
111
    const next = `${Number(num) + 1}${delimiter}`;
112
    return content === ''
113
      ? { action: 'remove' }
114
      : { action: 'insert', text: `${indent}${next} ` };
115
  }
88 116
}
89 117

  
90 118
class TextileListFormatter {
91 119
  format(line) {
92
    // Match list items in Textile syntax.
93
    // Captures either an ordered list (using '#') or an unordered list (using '*').
94
    // The regex structure:
95
    // ^([*#]+)            → one or more list markers: '*' for unordered, '#' for ordered
96
    // (.*)$               → the actual list item content
97
    //
98 120
    // Examples:
99 121
    // '# ordered text'            → marker='#',  content='ordered text'
100 122
    // '## nested ordered text'    → marker='##', content='nested ordered text'
101 123
    // '* unordered text'          → marker='*',  content='unordered text'
102 124
    // '** nested unordered text'  → marker='**', content='nested unordered text'
103
    const match = line.match(/^([*#]+) (.*)$/)
125
    const match = line.match(/^(?<marker>[*#]+) (?<content>.*)$/);
104 126
    if (!match) return null
105 127

  
106
    const marker = match[1]
107
    const content = match[2]
108

  
109
    if (content === '') {
110
      return { action: 'remove' }
111
    }
112

  
113
    return { action: 'insert', text: `${marker} ` }
128
    const { marker, content } = match.groups;
129
    return content === ''
130
      ? { action: 'remove' }
131
      : { action: 'insert', text: `${marker} ` };
114 132
  }
115 133
}
116 134

  
test/system/list_autofill_test.rb
127 127
    end
128 128
  end
129 129

  
130
  def test_autofill_with_markdown_unchecked_task_list
131
    with_settings :text_formatting => 'common_mark' do
132
      visit '/projects/ecookbook/issues/new'
133

  
134
      within('form#issue-form') do
135
        find('#issue_description').send_keys('- [ ] First item')
136
        find('#issue_description').send_keys(:enter)
137

  
138
        assert_equal(
139
          "- [ ] First item\n" \
140
          "- [ ] ",
141
          find('#issue_description').value
142
        )
143

  
144
        fill_in 'Description', with: ''
145
        find('#issue_description').send_keys('1. [ ] First item')
146
        find('#issue_description').send_keys(:enter)
147

  
148
        assert_equal(
149
          "1. [ ] First item\n" \
150
          "2. [ ] ",
151
          find('#issue_description').value
152
        )
153
      end
154
    end
155
  end
156

  
157
  def test_autofill_with_markdown_checked_task_list
158
    with_settings :text_formatting => 'common_mark' do
159
      visit '/projects/ecookbook/issues/new'
160

  
161
      within('form#issue-form') do
162
        find('#issue_description').send_keys('- [x] First item')
163
        find('#issue_description').send_keys(:enter)
164

  
165
        assert_equal(
166
          "- [x] First item\n" \
167
          "- [ ] ",
168
          find('#issue_description').value
169
        )
170

  
171
        fill_in 'Description', with: ''
172
        find('#issue_description').send_keys('1. [x] First item')
173
        find('#issue_description').send_keys(:enter)
174

  
175
        assert_equal(
176
          "1. [x] First item\n" \
177
          "2. [ ] ",
178
          find('#issue_description').value
179
        )
180
      end
181
    end
182
  end
183

  
130 184
  def test_textile_nested_list_autofill
131 185
    with_settings :text_formatting => 'textile' do
132 186
      visit '/projects/ecookbook/issues/new'
    (1-1/1)