Almirah 0.3.1 → 0.4.1

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.
@@ -1,3 +1,7 @@
1
+ require 'cgi'
2
+ require 'uri'
3
+ require_relative '../relative_url'
4
+
1
5
  class TextLineToken
2
6
  attr_accessor :value
3
7
 
@@ -48,20 +52,47 @@ class SquareBracketRight < TextLineToken
48
52
  end
49
53
  end
50
54
 
55
+ class DoubleSquareBracketLeft < TextLineToken
56
+ def initialize # rubocop:disable Lint/MissingSuper
57
+ @value = '[['
58
+ end
59
+ end
60
+
61
+ class DoubleSquareBracketRight < TextLineToken
62
+ def initialize # rubocop:disable Lint/MissingSuper
63
+ @value = ']]'
64
+ end
65
+ end
66
+
51
67
  class SquareBracketRightAndParentheseLeft < TextLineToken
52
68
  def initialize
53
69
  @value = ']('
54
70
  end
55
71
  end
56
72
 
73
+ class BacktickToken < TextLineToken
74
+ def initialize # rubocop:disable Lint/MissingSuper
75
+ @value = '`'
76
+ end
77
+ end
78
+
79
+ class InlineCodeToken < TextLineToken
80
+ def initialize(raw) # rubocop:disable Lint/MissingSuper
81
+ @value = raw
82
+ end
83
+ end
84
+
57
85
  class TextLineParser
58
86
  attr_accessor :supported_tokens
59
87
 
60
- def initialize # rubocop:disable Metrics/AbcSize
88
+ def initialize # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
61
89
  @supported_tokens = []
62
90
  @supported_tokens.append(BoldAndItalicToken.new)
63
91
  @supported_tokens.append(BoldToken.new)
64
92
  @supported_tokens.append(ItalicToken.new)
93
+ @supported_tokens.append(BacktickToken.new)
94
+ @supported_tokens.append(DoubleSquareBracketLeft.new)
95
+ @supported_tokens.append(DoubleSquareBracketRight.new)
65
96
  @supported_tokens.append(SquareBracketRightAndParentheseLeft.new)
66
97
  @supported_tokens.append(ParentheseLeft.new)
67
98
  @supported_tokens.append(ParentheseRight.new)
@@ -70,7 +101,7 @@ class TextLineParser
70
101
  @supported_tokens.append(TextLineToken.new)
71
102
  end
72
103
 
73
- def tokenize(str)
104
+ def tokenize(str) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
74
105
  result = []
75
106
  sl = str.length
76
107
  si = 0
@@ -79,29 +110,110 @@ class TextLineParser
79
110
  tl = t.value.length
80
111
  if tl != 0 # literal is the last supported token in the list
81
112
  projected_end_position = si + tl - 1
82
- next if projected_end_position > sl
113
+ next if projected_end_position >= sl
83
114
 
84
115
  buf = str[si..projected_end_position]
85
- if buf == t.value
116
+ next unless buf == t.value
117
+
118
+ if emphasis_token?(t) && !can_flank?(str, si, projected_end_position)
119
+ append_literal(result, buf)
120
+ else
86
121
  result.append(t)
87
- si = projected_end_position + 1
88
- break
89
122
  end
123
+ si = projected_end_position + 1
124
+ break
90
125
  else
91
- if result.length.positive? && (result[-1].instance_of? TextLineToken)
92
- literal = result[-1]
93
- literal.value += str[si]
94
- else
95
- literal = TextLineToken.new
96
- literal.value = str[si]
97
- result.append(literal)
98
- end
126
+ append_literal(result, str[si])
99
127
  si += 1
100
128
  end
101
129
  end
102
130
  end
131
+ fuse_backticks(result)
132
+ end
133
+
134
+ private
135
+
136
+ def fuse_backticks(tokens) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
137
+ result = []
138
+ i = 0
139
+ while i < tokens.length
140
+ if tokens[i].instance_of?(BacktickToken)
141
+ closer = next_backtick_index(tokens, i + 1)
142
+ if closer
143
+ raw = tokens[(i + 1)..(closer - 1)].map(&:value).join
144
+ result.append(InlineCodeToken.new(raw))
145
+ i = closer + 1
146
+ else
147
+ append_literal(result, '`')
148
+ i += 1
149
+ end
150
+ else
151
+ result.append(tokens[i])
152
+ i += 1
153
+ end
154
+ end
103
155
  result
104
156
  end
157
+
158
+ def next_backtick_index(tokens, start_idx)
159
+ idx = start_idx
160
+ while idx < tokens.length
161
+ return idx if tokens[idx].instance_of?(BacktickToken)
162
+
163
+ idx += 1
164
+ end
165
+ nil
166
+ end
167
+
168
+ def emphasis_token?(token)
169
+ token.is_a?(ItalicToken) || token.is_a?(BoldToken) || token.is_a?(BoldAndItalicToken)
170
+ end
171
+
172
+ def append_literal(result, text)
173
+ if !result.empty? && result[-1].instance_of?(TextLineToken)
174
+ result[-1].value += text
175
+ else
176
+ literal = TextLineToken.new
177
+ literal.value = text.dup
178
+ result.append(literal)
179
+ end
180
+ end
181
+
182
+ def can_flank?(str, start_idx, end_idx)
183
+ left_flanking?(str, start_idx, end_idx) || right_flanking?(str, start_idx, end_idx)
184
+ end
185
+
186
+ def left_flanking?(str, start_idx, end_idx)
187
+ after = char_at(str, end_idx + 1)
188
+ return false if after.nil? || whitespace?(after)
189
+ return true unless punctuation?(after)
190
+
191
+ before = char_at(str, start_idx - 1)
192
+ before.nil? || whitespace?(before)
193
+ end
194
+
195
+ def right_flanking?(str, start_idx, end_idx)
196
+ before = char_at(str, start_idx - 1)
197
+ return false if before.nil? || whitespace?(before)
198
+ return true unless punctuation?(before)
199
+
200
+ after = char_at(str, end_idx + 1)
201
+ after.nil? || whitespace?(after)
202
+ end
203
+
204
+ def char_at(str, idx)
205
+ return nil if idx.negative? || idx >= str.length
206
+
207
+ str[idx]
208
+ end
209
+
210
+ def whitespace?(char)
211
+ char.match?(/\s/)
212
+ end
213
+
214
+ def punctuation?(char)
215
+ char.match?(/[[:punct:]]/)
216
+ end
105
217
  end
106
218
 
107
219
  class TextLineBuilderContext
@@ -117,9 +229,17 @@ class TextLineBuilderContext
117
229
  str
118
230
  end
119
231
 
232
+ def inline_code(str)
233
+ str
234
+ end
235
+
120
236
  def link(_link_text, link_url)
121
237
  link_url
122
238
  end
239
+
240
+ def wiki_link(inner)
241
+ "[[#{inner}]]"
242
+ end
123
243
  end
124
244
 
125
245
  class TextLineBuilder
@@ -129,7 +249,7 @@ class TextLineBuilder
129
249
  @builder_context = builder_context
130
250
  end
131
251
 
132
- def restore(token_list)
252
+ def restore(token_list) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
133
253
  result = ''
134
254
  return '' if token_list.nil?
135
255
 
@@ -196,6 +316,25 @@ class TextLineBuilder
196
316
  result += '***'
197
317
  ti = ti_starting_position + 1
198
318
  end
319
+ when 'DoubleSquareBracketLeft'
320
+ # wiki/Obsidian link: collect raw text up to the closing "]]"
321
+ is_found = false
322
+ ti_starting_position = ti
323
+ tii = ti + 1
324
+ while tii < tl
325
+ if token_list[tii].instance_of?(DoubleSquareBracketRight)
326
+ inner = token_list[(ti + 1)..(tii - 1)].map(&:value).join
327
+ result += @builder_context.wiki_link(inner)
328
+ ti = tii + 1
329
+ is_found = true
330
+ break
331
+ end
332
+ tii += 1
333
+ end
334
+ unless is_found
335
+ result += '[['
336
+ ti = ti_starting_position + 1
337
+ end
199
338
  when 'SquareBracketLeft'
200
339
  # try to find closing part
201
340
  is_found = false
@@ -230,7 +369,10 @@ class TextLineBuilder
230
369
  ti = ti_starting_position + 1
231
370
  end
232
371
 
233
- when 'TextLineToken', 'ParentheseLeft', 'ParentheseRight', 'SquareBracketRight'
372
+ when 'InlineCodeToken'
373
+ result += @builder_context.inline_code(token_list[ti].value)
374
+ ti += 1
375
+ when 'TextLineToken', 'ParentheseLeft', 'ParentheseRight', 'SquareBracketRight', 'DoubleSquareBracketRight'
234
376
  result += token_list[ti].value
235
377
  ti += 1
236
378
  else
@@ -242,11 +384,37 @@ class TextLineBuilder
242
384
  end
243
385
 
244
386
  class TextLine < TextLineBuilderContext
245
- @@lazy_doc_id_dict = {}
387
+ @@link_registry = nil # rubocop:disable Style/ClassVars
388
+ @@broken_links = [] # rubocop:disable Style/ClassVars
246
389
 
247
- def self.add_lazy_doc_id(id)
248
- doc_id = id.to_s.downcase
249
- @@lazy_doc_id_dict[doc_id] = doc_id
390
+ class << self
391
+ def link_registry=(registry)
392
+ @@link_registry = registry # rubocop:disable Style/ClassVars
393
+ end
394
+
395
+ def link_registry
396
+ @@link_registry
397
+ end
398
+
399
+ # Cross-document links that could not be resolved to a managed document,
400
+ # collected during rendering for reporting (ADR-186, SRS-094).
401
+ def broken_links
402
+ @@broken_links
403
+ end
404
+
405
+ def reset_broken_links
406
+ @@broken_links = [] # rubocop:disable Style/ClassVars
407
+ end
408
+
409
+ def record_broken_link(document, target)
410
+ @@broken_links << { document: document&.id, target: target }
411
+ end
412
+ end
413
+
414
+ # The document that owns this text line. Used to resolve cross-document links
415
+ # relative to the current page. nil for stand-alone text (e.g. unit tests).
416
+ def owner_document
417
+ nil
250
418
  end
251
419
 
252
420
  def format_string(str)
@@ -267,30 +435,64 @@ class TextLine < TextLineBuilderContext
267
435
  "<b><i>#{str}</i></b>"
268
436
  end
269
437
 
270
- def link(link_text, link_url)
271
- # define default result first
272
- result = "<a target=\"_blank\" rel=\"noopener\" href=\"#{link_url}\" class=\"external\">#{link_text}</a>"
273
-
274
- lazy_doc_id = nil
275
- anchor = nil
276
-
277
- if res = /(\w+)[.]md$/.match(link_url) # link
278
- lazy_doc_id = res[1].to_s.downcase
438
+ def inline_code(str)
439
+ "<code class=\"inline\">#{CGI.escapeHTML(str)}</code>"
440
+ end
279
441
 
280
- elsif res = /(\w*)[.]md(#.*)$/.match(link_url) # link with anchor
281
- if res && res.length > 2
282
- lazy_doc_id = res[1]
283
- anchor = res[2]
284
- end
442
+ def link(link_text, link_url) # rubocop:disable Metrics/MethodLength
443
+ raw = link_url.to_s
444
+ kind, target, fragment = classify_markdown_link(raw)
445
+ case kind
446
+ when :internal
447
+ href = RelativeUrl.between(owner_document.output_rel_path, target.output_rel_path, fragment: fragment)
448
+ "<a href=\"#{href}\" class=\"external\">#{link_text}</a>"
449
+ when :broken
450
+ TextLine.record_broken_link(owner_document, raw)
451
+ "<a href=\"#{raw}\" class=\"broken_link\" title=\"Unresolved cross-document link\">#{link_text}</a>"
452
+ else
453
+ "<a target=\"_blank\" rel=\"noopener\" href=\"#{raw}\" class=\"external\">#{link_text}</a>"
285
454
  end
455
+ end
286
456
 
287
- if lazy_doc_id && @@lazy_doc_id_dict.key?(lazy_doc_id)
288
- result = if anchor
289
- "<a href=\".\\..\\#{lazy_doc_id}\\#{lazy_doc_id}.html#{anchor}\" class=\"external\">#{link_text}</a>"
290
- else
291
- "<a href=\".\\..\\#{lazy_doc_id}\\#{lazy_doc_id}.html\" class=\"external\">#{link_text}</a>"
292
- end
457
+ # Resolves an Obsidian/wiki link "[[target#fragment|alias]]" to a managed
458
+ # document by its unique id/filename, independent of folder (ADR-186).
459
+ def wiki_link(inner) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize,Metrics/MethodLength
460
+ link_part, sep, alias_text = inner.partition('|')
461
+ target, _hash, fragment = link_part.partition('#')
462
+ display = (sep.empty? ? link_part : alias_text).strip
463
+ display = link_part.strip if display.empty?
464
+
465
+ doc = TextLine.link_registry&.find_by_id(target.strip)
466
+ if doc && owner_document&.output_rel_path
467
+ href = RelativeUrl.between(owner_document.output_rel_path, doc.output_rel_path,
468
+ fragment: fragment.strip.empty? ? nil : fragment.strip)
469
+ "<a href=\"#{href}\" class=\"external\">#{CGI.escapeHTML(display)}</a>"
470
+ elsif owner_document&.output_rel_path
471
+ TextLine.record_broken_link(owner_document, "[[#{inner}]]")
472
+ "<span class=\"broken_link\" title=\"Unresolved wiki link\">#{CGI.escapeHTML(display)}</span>"
473
+ else
474
+ "[[#{inner}]]"
293
475
  end
294
- result
476
+ end
477
+
478
+ private
479
+
480
+ # Classifies a Markdown link target as :internal (a managed document, resolved
481
+ # against the owning document's source directory), :broken (a local .md path
482
+ # that does not resolve), or :external (everything else). ADR-186.
483
+ def classify_markdown_link(raw) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
484
+ path_part, _sep, fragment = raw.partition('#')
485
+ path_part = URI::DEFAULT_PARSER.unescape(path_part) # decode %20 etc. to match the real file path
486
+ return [:external] unless local_markdown_path?(path_part)
487
+
488
+ doc = owner_document
489
+ return [:external] unless doc&.output_rel_path && doc.respond_to?(:path) && doc.path && TextLine.link_registry
490
+
491
+ target = TextLine.link_registry.find_by_source(File.expand_path(path_part, File.dirname(doc.path)))
492
+ target ? [:internal, target, fragment.empty? ? nil : fragment] : [:broken]
493
+ end
494
+
495
+ def local_markdown_path?(path_part)
496
+ path_part.match?(/\.(md|markdown)\z/i) && !path_part.match?(%r{\A[a-z][a-z0-9+.\-]*://}i)
295
497
  end
296
498
  end
@@ -174,7 +174,7 @@ class DocParser # rubocop:disable Metrics/ClassLength,Style/Documentation
174
174
 
175
175
  doc.items.append(item)
176
176
 
177
- elsif res = /^(\*\s+)(.*)/.match(s) # check if unordered list start
177
+ elsif res = /^([*\-]\s+)(.*)/.match(s) # check if unordered list start
178
178
 
179
179
  if doc.title == ''
180
180
  # dummy section if root is not a Document Title (level 0)
@@ -248,8 +248,9 @@ class DocParser # rubocop:disable Metrics/ClassLength,Style/Documentation
248
248
 
249
249
  if temp_md_table
250
250
  if temp_md_table.is_separator_detected # if there is a separator
251
- # check if parent doc is a Protocol
252
- if doc.instance_of? Protocol
251
+ # check if parent doc is a Protocol, or a Decision Record inside an "Affected Documents" section
252
+ if doc.instance_of?(Protocol) ||
253
+ (doc.instance_of?(Decision) && in_section?(doc, 'Affected Documents'))
253
254
  # check if it is a controlled table
254
255
  tmp = /(.*)\s+>\[(\S*)\]/.match(row)
255
256
  if tmp && (temp_md_table.instance_of? MarkdownTable)
@@ -379,4 +380,20 @@ class DocParser # rubocop:disable Metrics/ClassLength,Style/Documentation
379
380
  end
380
381
  temp_md_table
381
382
  end
383
+
384
+ # Returns true when the most recently parsed heading is inside (or equal to) a section
385
+ # whose heading text matches `section_name`. Walks back through the heading list
386
+ # accumulating the ancestor chain (strictly decreasing levels).
387
+ def self.in_section?(doc, section_name)
388
+ return false if doc.headings.empty?
389
+
390
+ last_level = doc.headings[-1].level + 1
391
+ doc.headings.reverse_each do |h|
392
+ next if h.level >= last_level
393
+
394
+ last_level = h.level
395
+ return true if h.text.strip == section_name
396
+ end
397
+ false
398
+ end
382
399
  end
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../relative_url'
4
+
3
5
  class BaseDocument # rubocop:disable Style/Documentation
4
- attr_accessor :title, :id, :dom, :headings
6
+ attr_accessor :title, :id, :dom, :headings, :output_rel_path
7
+
8
+ class << self
9
+ attr_accessor :show_decisions_link
10
+ end
5
11
 
6
12
  def initialize
7
13
  @items = []
@@ -21,9 +27,14 @@ class BaseDocument # rubocop:disable Style/Documentation
21
27
 
22
28
  output_file_path += if @id == 'index'
23
29
  "#{@id}.html"
30
+ elsif instance_of? DecisionsOverview
31
+ 'overview.html'
32
+ elsif instance_of? Decision
33
+ "#{@id}.html"
24
34
  else
25
35
  "#{@id}/#{@id}.html"
26
36
  end
37
+ @output_rel_path = output_file_path.split('/build/', 2).last
27
38
  file = File.open(output_file_path, 'w')
28
39
  file_data.each do |s| # rubocop:disable Metrics/BlockLength
29
40
  if s.include?('{{CONTENT}}')
@@ -35,35 +46,21 @@ class BaseDocument # rubocop:disable Style/Documentation
35
46
  elsif s.include?('{{DOCUMENT_TITLE}}')
36
47
  file.puts s.gsub! '{{DOCUMENT_TITLE}}', @title
37
48
  elsif s.include?('{{STYLES_AND_SCRIPTS}}')
49
+ file.puts "<link rel=\"stylesheet\" href=\"#{rel_to('css/main.css')}\">"
50
+ file.puts "<script src=\"#{rel_to('scripts/main.js')}\"></script>"
38
51
  if @id == 'index'
39
- file.puts '<script type="module" src="./scripts/orama_search.js"></script>'
40
- file.puts '<link rel="stylesheet" href="./css/search.css">'
41
- file.puts '<link rel="stylesheet" href="./css/main.css">'
42
- file.puts '<script src="./scripts/main.js"></script>'
43
- elsif instance_of? Specification
44
- file.puts '<link rel="stylesheet" href="../../css/main.css">'
45
- file.puts '<script src="../../scripts/main.js"></script>'
46
- elsif instance_of? Traceability
47
- file.puts '<link rel="stylesheet" href="../../css/main.css">'
48
- file.puts '<script src="../../scripts/main.js"></script>'
49
- elsif instance_of? Coverage
50
- file.puts '<link rel="stylesheet" href="../../css/main.css">'
51
- file.puts '<script src="../../scripts/main.js"></script>'
52
- elsif instance_of? Implementation
53
- file.puts '<link rel="stylesheet" href="../../css/main.css">'
54
- file.puts '<script src="../../scripts/main.js"></script>'
55
- elsif instance_of? Protocol
56
- file.puts '<link rel="stylesheet" href="../../../css/main.css">'
57
- file.puts '<script src="../../../scripts/main.js"></script>'
52
+ file.puts "<script type=\"module\" src=\"#{rel_to('scripts/orama_search.js')}\"></script>"
53
+ file.puts "<link rel=\"stylesheet\" href=\"#{rel_to('css/search.css')}\">"
54
+ elsif instance_of? DecisionsOverview
55
+ file.puts '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
58
56
  end
59
57
  elsif s.include?('{{HOME_BUTTON}}')
60
58
  if @id == 'index'
61
- file.puts '<a id="home_menu_item" href="./index.html"><span><i class="fa fa-home" aria-hidden="true"></i></span>&nbsp;Home</a>'
62
- elsif instance_of? Protocol
63
- file.puts '<a id="index_menu_item" href="./../../../index.html"><span><i class="fa fa-info" aria-hidden="true"></i></span>&nbsp;Index</a>'
59
+ file.puts home_link(rel_to('index.html'))
64
60
  else
65
- file.puts '<a id="index_menu_item" href="./../../index.html"><span><i class="fa fa-info" aria-hidden="true"></i></span>&nbsp;Index</a>'
61
+ file.puts index_link(rel_to('index.html'))
66
62
  end
63
+ file.puts decisions_link(rel_to('decisions/overview.html')) if BaseDocument.show_decisions_link
67
64
  elsif s.include?('{{GEM_VERSION}}')
68
65
  file.puts "(#{Gem.loaded_specs['Almirah'].version.version})"
69
66
  else
@@ -72,4 +69,24 @@ class BaseDocument # rubocop:disable Style/Documentation
72
69
  end
73
70
  file.close
74
71
  end
72
+
73
+ def decisions_link(href)
74
+ icon = '<span><i class="fa fa-gavel" aria-hidden="true"></i></span>'
75
+ %(<a id="decisions_menu_item" href="#{href}">#{icon}&nbsp;Decision Records</a>)
76
+ end
77
+
78
+ def index_link(href)
79
+ icon = '<span><i class="fa fa-info" aria-hidden="true"></i></span>'
80
+ %(<a id="index_menu_item" href="#{href}">#{icon}&nbsp;Index</a>)
81
+ end
82
+
83
+ def home_link(href)
84
+ icon = '<span><i class="fa fa-home" aria-hidden="true"></i></span>'
85
+ %(<a id="home_menu_item" href="#{href}">#{icon}&nbsp;Home</a>)
86
+ end
87
+
88
+ # Relative URL from this page to a target path under the build root.
89
+ def rel_to(target)
90
+ RelativeUrl.between(@output_rel_path, target)
91
+ end
75
92
  end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require_relative 'persistent_document'
5
+ require_relative '../doc_items/heading'
6
+ require_relative '../doc_items/markdown_table'
7
+
8
+ class Decision < PersistentDocument # rubocop:disable Style/Documentation,Metrics/ClassLength
9
+ attr_accessor :path, :sequence_number, :record_type, :html_rel_path, :root_prefix, :current_status,
10
+ :start_date, :target_release_version, :specifications_path, :wrong_links_hash
11
+
12
+ def initialize(file_path)
13
+ super
14
+ @path = file_path
15
+ stem = File.basename(file_path, File.extname(file_path))
16
+ assign_id_parts(stem)
17
+ @current_status = nil
18
+ @start_date = nil
19
+ @target_release_version = nil
20
+ @wrong_links_hash = {}
21
+ end
22
+
23
+ def to_console
24
+ puts "\e[36mDecision: #{@id}\e[0m"
25
+ end
26
+
27
+ def to_html(nav_pane, output_file_path)
28
+ html_rows = []
29
+ html_rows.append('')
30
+
31
+ @items.each do |item|
32
+ html_rows.append item.to_html
33
+ end
34
+
35
+ save_html_to_file(html_rows, nav_pane, output_file_path)
36
+ end
37
+
38
+ def extract_current_status
39
+ status_table = find_section_table('Status')
40
+ return if status_table.nil?
41
+
42
+ status_table.is_decision_status_table = true
43
+ marker_rows = status_table.rows.select { |row| row[0].to_s.strip == '*' }
44
+ @current_status = marker_rows.length == 1 ? marker_rows[0][-1].to_s.strip : nil
45
+ end
46
+
47
+ def extract_start_date
48
+ dates = collect_dates('Status', 'Date') + collect_dates('Scope', 'Start Date')
49
+ @start_date = dates.min
50
+ end
51
+
52
+ def extract_target_release_version
53
+ @target_release_version = lookup_cell(
54
+ section_name: 'Software Versions',
55
+ key_column: 'Software Version Category',
56
+ value_column: 'Software Version ID',
57
+ key: 'Target Release Version'
58
+ )
59
+ end
60
+
61
+ def effective_status_on(date) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
62
+ table = find_section_table('Status')
63
+ return nil if table.nil?
64
+
65
+ date_idx = column_index(table, 'Date')
66
+ status_idx = column_index(table, 'Status')
67
+ return nil if date_idx.nil? || status_idx.nil?
68
+
69
+ best_idx = nil
70
+ best_date = nil
71
+ table.rows.each_with_index do |row, i|
72
+ parsed = parse_dd_mm_yyyy(row[date_idx])
73
+ next if parsed.nil? || parsed > date
74
+
75
+ if best_date.nil? || parsed > best_date || (parsed == best_date && i > best_idx)
76
+ best_date = parsed
77
+ best_idx = i
78
+ end
79
+ end
80
+ return nil if best_idx.nil?
81
+
82
+ status = table.rows[best_idx][status_idx].to_s.strip
83
+ status.empty? ? nil : status
84
+ end
85
+
86
+ private
87
+
88
+ def lookup_cell(section_name:, key_column:, value_column:, key:) # rubocop:disable Metrics/AbcSize
89
+ table = find_section_table(section_name)
90
+ return nil if table.nil?
91
+
92
+ key_idx = column_index(table, key_column)
93
+ value_idx = column_index(table, value_column)
94
+ return nil if key_idx.nil? || value_idx.nil?
95
+
96
+ row = table.rows.find { |r| r[key_idx].to_s.strip == key }
97
+ return nil if row.nil?
98
+
99
+ cell = row[value_idx].to_s.strip
100
+ cell.empty? ? nil : cell
101
+ end
102
+
103
+ def find_section_table(section_name) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
104
+ in_section = false
105
+ section_level = nil
106
+ @items.each do |item|
107
+ if item.is_a?(Heading)
108
+ if !in_section && item.text.strip == section_name
109
+ in_section = true
110
+ section_level = item.level
111
+ elsif in_section && item.level <= section_level
112
+ return nil
113
+ end
114
+ elsif in_section && item.is_a?(MarkdownTable)
115
+ return item
116
+ end
117
+ end
118
+ nil
119
+ end
120
+
121
+ def collect_dates(section_name, column_name)
122
+ table = find_section_table(section_name)
123
+ return [] if table.nil?
124
+
125
+ col_index = column_index(table, column_name)
126
+ return [] if col_index.nil?
127
+
128
+ table.rows.filter_map { |row| parse_dd_mm_yyyy(row[col_index]) }
129
+ end
130
+
131
+ def column_index(table, column_name)
132
+ table.column_names.each_with_index do |name, idx|
133
+ return idx if name.to_s.strip == column_name
134
+ end
135
+ nil
136
+ end
137
+
138
+ def parse_dd_mm_yyyy(value)
139
+ return nil if value.nil?
140
+
141
+ match = /\A(\d{2})-(\d{2})-(\d{4})\z/.match(value.to_s.strip)
142
+ return nil unless match
143
+
144
+ Date.new(match[3].to_i, match[2].to_i, match[1].to_i)
145
+ rescue ArgumentError
146
+ nil
147
+ end
148
+
149
+ def assign_id_parts(stem)
150
+ match = stem.match(/\A([A-Za-z]+)-(\d+)/)
151
+ if match
152
+ @id = "#{match[1]}-#{match[2]}".downcase
153
+ @record_type = match[1].upcase
154
+ @sequence_number = match[2]
155
+ else
156
+ @id = stem.downcase
157
+ @record_type = nil
158
+ @sequence_number = nil
159
+ end
160
+ end
161
+ end